Skip to content

Collect PostgreSQL configuration and what changed in it (#2658), and register ten MCP tools that shipped unreachable (#2659) - #2660

Merged
erikdarlingdata merged 3 commits into
devfrom
feat/2658-pg-server-config
Aug 26, 2026
Merged

Collect PostgreSQL configuration and what changed in it (#2658), and register ten MCP tools that shipped unreachable (#2659)#2660
erikdarlingdata merged 3 commits into
devfrom
feat/2658-pg-server-config

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2658. Closes #2659.

#2658 — configuration

pg_settings was never collected. SQL Server answers this three ways; PostgreSQL had no answer at all. The
second question is the one that matters most and it is the one that cannot be recovered later at any price:
a configuration history nobody recorded is not sitting on the server waiting to be read.

  • V102 stores the snapshot hourly, 365 days. Hourly because configuration changes when a person changes
    it; 365 days because this gets asked long after the fact ("it got slow sometime last quarter").
  • get_pg_server_config — what somebody actually chose, non-default first, with source, context
    (restart / reload / nothing) and the compiled-in default beside each value.
  • get_pg_server_config_changes — value changes between snapshots, old beside new.
  • A Configuration tab in the web dashboard, changes above settings: someone opening it during an
    incident is asking what moved, not what the server is.

Two decisions that were wrong first and got fixed by running it

Defaultness comes from PostgreSQL's own source, not from comparing setting to boot_val. The string
comparison looks equivalent and invents non-defaults on a server nobody configured. Measured on a live
17.11:

setting setting boot_val source
data_directory_mode 0700 448 default
archive_command (disabled) (empty) default
commit_timestamp_buffers 32 0 default

Octal against decimal, a display convention, and an auto-tuned value the server resolved at startup. All
three say source = 'default', which is PostgreSQL stating plainly that nobody set them. boot_val is
still stored and shown — it is useful to see the default — it just does not get to decide.

A setting appearing is not a change. LAG returns NULL for the first snapshot of every setting, so
without the guard the first collection reports several hundred fabricated changes, and does it again for
every extension whose GUCs appear when its library loads. On the rig the first snapshot carried 415 settings
and the read correctly reported one change.

Session rows are stored and filtered at the read, not dropped at collection

pg_settings is a per-BACKEND view, so a client-source row describes the monitoring connection. Dropping
it at collection would make the evidence unrecoverable and still leave every read guessing. Showing one as
the server's configuration would be wrong; reporting one as a change would be worse — the collector
reconnects, application_name moves, and the read announces a change nobody made.

#2659 — ten tools nobody could call

Found while wiring the above. Six [McpServerToolType] classes were never registered with the MCP host:

tools/list  ->  116 tools, 13 get_pg_*
census      ->  126 tools, 23 PostgreSQL reads

get_pg_write_stats, get_pg_buffer_usage, get_pg_extensions, get_pg_lock_stats, get_pg_index_bloat,
get_pg_column_stats, get_pg_kernel_stats, get_pg_predicate_stats, get_pg_replication_stats,
get_pg_wait_sampling — implemented, documented, dispatched by the web dashboard, counted in the census,
and unreachable by any agent.

Nothing failed, because neither existing guard looks at this: the inventory pin checks tool names, which
exist whether or not the class is registered, and the POSTGRES_TABS pin exists (its header says so) to stop
a read shipping reachable only through MCP — this is the exact inverse. A reflection-derived pin now
asserts every tool class is registered, so it fails when someone adds a class rather than when an agent next
reaches for the tool.

Fixed here rather than separately because two of the ten are this change's own tools, and shipping a feature
into a class agents cannot reach is shipping nothing.

Verification

End to end against the rig through the real service:

  • store migrated to v102
  • tools/list 116 → 128, with 25 PostgreSQL reads — matching the updated census exactly
  • ALTER SYSTEM SET work_mem = '8MB' then a second snapshot: the changes read reported exactly one
    change out of 415 settings, work_mem 4096 → 8192
  • ALTER SYSTEM SET shared_buffers = '256MB' + reload: correctly reported as pending_restart, value
    unchanged — the file and the running server disagreeing, which is the state the read exists to surface

All 22 assertions in the new tests were validated locally against the product assemblies before pushing,
including that the V102 rung's DDL is byte-identical to the generated schema — the check that cost a CI
round on the previous rung.

…register ten MCP tools that shipped unreachable (#2659)

pg_settings was never collected. SQL Server answers this three ways and
PostgreSQL had no answer at all, so neither "what is work_mem set to here" nor
"what changed last Tuesday" could be answered -- and the second is the kind that
cannot be recovered afterwards at any price, because a configuration history
nobody recorded is not sitting on the server waiting to be read.

V102 stores the snapshot hourly, 365 days, and stores every column without
filtering. source is what separates the server's configuration from the
collector's own session -- pg_settings is a per-backend view -- and dropping a
session-scoped row at collection time would make that evidence unrecoverable, so
both reads filter instead, where it can be explained. Reporting one as a CHANGE
would be worse than showing it: the collector reconnects, application_name moves,
and the read would announce a change nobody made.

get_pg_server_config reports what somebody actually chose, non-default first.
Defaultness comes from PostgreSQL's own `source`, NOT from comparing setting to
boot_val -- the string comparison invents non-defaults on a server nobody
configured, measured on a live 17.11: data_directory_mode reads 0700 against a
boot_val of 448, the same value in octal and decimal; archive_command reads
(disabled) against an empty default; commit_timestamp_buffers reads 32 against 0
because 0 means auto-tune. That defect was in the first version of this read and
only running it found it.

get_pg_server_config_changes reports value changes between snapshots. A setting
APPEARING is deliberately not a change: LAG returns NULL for the first snapshot
of every setting, so without the guard the first collection would manufacture
several hundred changes, and would again for every extension whose GUCs appear
when its library loads.

Both name pending_restart loudly -- the file edited and reloaded while the
running server is still on the old value, disagreeing with no symptom until a
restart months later changes behaviour during someone else's incident.

#2659, found while wiring the above: six [McpServerToolType] classes -- ten
shipped PostgreSQL reads -- were never registered with the MCP host. They were
implemented, documented, dispatched by the web dashboard and counted in the
census, and tools/list answered 116 where the census claimed 126. Nothing failed:
the inventory pin checks NAMES, which exist either way, and the tab pin exists to
stop a read shipping reachable only through MCP, which is the exact inverse of
this. Registering them here rather than separately because two of the ten are
this change's own tools, and shipping a feature into a class agents cannot reach
is shipping nothing. A reflection-derived pin now asserts every tool class is
registered, so it fails when a class is added rather than when an agent next
reaches for the tool.

Verified end to end against the rig through the real service: store at v102,
tools/list 116 -> 128 with 25 PostgreSQL reads matching the census exactly, and
after ALTER SYSTEM on the target the changes read reported exactly one change
(work_mem 4096 -> 8192) out of 415 settings while shared_buffers correctly showed
pending_restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +397 to +409
var shown = include_defaults ? rows : rows.Where(r => !r.IsDefault).ToList();
var pendingRestart = rows.Where(r => r.PendingRestart).Select(r => r.Name).ToList();

return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
status = "server_config",
/* Both counts, because they answer different questions and one without the other invites
the wrong conclusion: a small returned count is reassuring only if you know it was
filtered rather than truncated. */
settings_returned = shown.Count,
non_default_count = rows.Count(r => !r.IsDefault),
truncated = rows.Count >= limit,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non_default_count doesn't mean what the comment above it says.

rows here has already had the SQL LIMIT $2 (= limit) applied inside GetCurrentConfigAsync, ordered pending_restart DESC, is_default ASC, name. So when a server has more non-default settings than limit (very plausible — Timescale/Citus/pg_partman/pg_cron etc. each add a batch of GUCs, and the doc comment for PgServerConfigCollector notes 28 non-default out of 415 was just one measurement), rows.Count(r => !r.IsDefault) isn't the true count of non-default settings on the server — it's the count of non-default settings within the already-truncated top-limit rows, which will just equal shown.Count (since ordering puts non-default rows first).

The comment right above this ("Both counts... a small returned count is reassuring only if you know it was filtered rather than truncated") implies non_default_count is meant to be the true total so a caller can tell filtering from truncation. It can't do that job while it's computed from the same truncated rows list — truncated will be true but non_default_count will silently look like a complete answer.

Two ways to fix: compute non_default_count with a separate COUNT(*) WHERE NOT is_default query (untruncated), or drop the claim that this field represents the total and document it as "non-default count among the returned rows."

/// majors, and an unknown one is far more likely to be a real server source than a new kind of
/// session state, so an unrecognised value should show up rather than vanish.
/// </summary>
public const string SessionScopedSources = "'client', 'session', 'override'";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth double-checking 'override' belongs in this list. The doc comment above frames SessionScopedSources as "values that describe THIS connection rather than the server," which is accurate for client and session — but PostgreSQL's PGC_S_OVERRIDE source isn't primarily about the connecting session. It's used for a handful of GUCs the backend forces regardless of any configured value (things like in_hot_standby, server_version, lc_collate/lc_ctype, wal_segment_size) — genuine, stable server-level facts, not the monitoring connection's own state (that's is_superuser/session_authorization, which do vary by connecting role).

As written, both CurrentConfigSql and ConfigChangesSql silently drop every override-sourced setting from get_pg_server_config and get_pg_server_config_changes. If any of those turn out to be facts worth surfacing (e.g. in_hot_standby flipping is exactly the kind of thing "what changed" should catch), they're unreachable through either read. Worth confirming against SELECT name, source FROM pg_settings WHERE source = 'override' on a real target before shipping — if it's intentional, a short note on why override rows are being treated as session noise (same as client/session) would help the next reader.

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Reviewed the diff for both #2658 (pg_settings collection + reads) and #2659 (MCP tool registration fix).

#2659 registration fix — verified correct. Diffed the full set of [McpServerToolType]-attributed classes in Darling/PerformanceMonitor.Darling.Service/Mcp/*.cs against every .WithGeminiCompatibleTools<T>() call in DarlingMcpHostService.cs after this PR: they match exactly, all 45 classes. McpToolTypeRegistrationTests correctly encodes this as a reflection-vs-source-scan check rather than a hand-kept list, so it stays valid as new tool classes are added.

Lite/Darling parity — no drift. This feature is Darling-only by design (PostgreSQL/Aurora central-store data), and Lite.Tests/CrossAppMcpToolInventoryPinTests.cs was correctly updated to add the two new tool names to the Darling-only inventory list, consistent with how the other get_pg_* tools are already handled there.

Schema/migration — V102 follows the established pattern (schema-qualified collect.pg_server_config, nullable columns matching the generated-schema test's expectations, StorageVersion bump, ViewerDataService probe/sentinel updated with a new arm). Column order in the DDL matches PgServerConfigCollector.PayloadColumns exactly, which is what PgSchemaGeneratorTests requires.

Two things worth a look, left as inline comments:

  • DarlingMcpPgServerStateTools.cs (GetPgServerConfig): the SQL LIMIT is applied before the include_defaults filter, so non_default_count isn't actually the true non-default total once a server has more non-default settings than limit — it silently degrades to the truncated count, undermining the "filtered vs. truncated" distinction the surrounding comment claims to provide.
  • DarlingPgServerConfigReader.cs (SessionScopedSources): lumping PostgreSQL's 'override' source in with client/session as "connection noise" may not hold — override also covers server-wide computed facts (e.g. in_hot_standby) that aren't specific to the monitoring connection, and both reads currently drop them silently.

Everything else — the source-not-boot_val defaultness logic, the LAG/prev_time IS NOT NULL guard against fabricated first-snapshot changes, the naive-UTC DateTime.SpecifyKind discipline in GetConfigChangesAsync, the hourly/365-day schedule choice, and the new Configuration tab wiring in server-tabs.js — checked out.

Eight failures, every one a pin doing its job, and together they are the
checklist for adding a PostgreSQL collector.

The viewer half was the real work. EveryPostgresCollector_IsShownOnExactlyOnePostgresTab
and EveryCollectorTable_HasAViewerReader_OrIsAllowListed both refused a collector
the WPF viewer never shows -- the allow-list is a shrink-only ratchet, so the
answer is a panel, not an entry. The settings grid sits under the extension axis
on the Overview tab because it is the same kind of fact one layer in: extensions
say what this server CAN do, settings say what it was told to do. It is the one
panel on that tab NOT scoped to the toolbar window, because a configuration is
the state now and an hours filter returns nothing for a server whose hourly
collector last ran just outside it -- which reads as "no configuration" rather
than "widen the window".

The rest were counts a new collector legitimately moves: the pg rung list, the
catalog count, the PostgreSQL tab count (7 -> 8, prose included), and the
CapturePathByCollector noun phrase without which the capability message falls
back to generic phrasing. The web tab used format: "datetime", which the renderer
does not know -- an unknown format falls through to raw text, so the column still
renders and the defect is invisible on inspection. It is "time".

CiClusterWorkerSizingTests corrected a factual error in V102's own doc comment
rather than just a number. I had written that pg_server_config is not a
hypertable, arguing from its shape -- a snapshot of something a person changes,
not a series of measurements. TimescaleSupport.HypertableTables is
CollectorCatalog.All, so membership follows from being a collector and is not a
per-table choice, and CI sizes its cluster's workers off that count. The doc now
says so, and both workflows move to 70/81.

Every pin re-validated locally against the product assemblies before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +397 to +409
var shown = include_defaults ? rows : rows.Where(r => !r.IsDefault).ToList();
var pendingRestart = rows.Where(r => r.PendingRestart).Select(r => r.Name).ToList();

return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
status = "server_config",
/* Both counts, because they answer different questions and one without the other invites
the wrong conclusion: a small returned count is reassuring only if you know it was
filtered rather than truncated. */
settings_returned = shown.Count,
non_default_count = rows.Count(r => !r.IsDefault),
truncated = rows.Count >= limit,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetCurrentConfigAsync applies LIMIT $2 (the limit param, default 100) in SQL, and only after that fixed-size result comes back does this method filter out defaults (rows.Where(r => !r.IsDefault)). Because the query's ORDER BY puts every non-default row ahead of every default row, this is safe only while the target's true non-default-setting count is ≤ limit. On a target where it exceeds limit — plausible on Aurora/RDS, where parameter groups routinely diverge on 100+ GUCs, exactly the kind of target this PR is aimed at — the SQL LIMIT silently cuts off some genuinely non-default settings before the C# filter ever sees them (an arbitrary, alphabetically-truncated subset, not "least important last").

Worse, non_default_count (line 408) is computed from that same already-truncated rows, so it quietly undercounts the server's real non-default-setting total in this scenario — there's no signal in the field itself that it's a partial count; only cross-referencing the generically-named truncated flag would reveal it, and an LLM/dashboard consumer has no reason to make that connection.

Consider filtering on defaultness in the SQL (behind include_defaults) so LIMIT operates on the same population that's being counted and returned, rather than filtering client-side after an unrelated cap.

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Reviewed the PostgreSQL server-config feature (#2658) and the MCP tool-registration fix (#2659). Overall this is well-executed and unusually well self-verified:

  • Column order between PgServerConfigCollector.PayloadColumns/WritePayload, the V102Sql DDL, and the two DarlingPgServerConfigReader SQL projections all line up correctly with their respective reader.GetX(i) indices — checked by hand, no off-by-one.
  • is_default is correctly derived from source, not from a setting/boot_val string compare, matching the PR's stated (and well-justified) design decision.
  • The LAG(...) ... WHERE prev_time IS NOT NULL guard genuinely prevents first-snapshot rows from being reported as fabricated changes.
  • Session-scoped source values are filtered at the read, not the collector — the collector stores everything, as documented. Good separation.
  • Lite/Darling parity: this is PostgreSQL-only functionality, and Lite has no PostgreSQL target at all (DuckDbSchemaGenerator.StoredCollectors filters PG tables out entirely), so adding get_pg_server_config/get_pg_server_config_changes to Lite.Tests/CrossAppMcpToolInventoryPinTests.cs's KnownLiteMissingMcpTools ratchet (rather than porting them) is the correct, established pattern for this codebase — consistent with every other get_pg_* tool.
  • McpToolTypeRegistrationTests's reflection-vs-source-regex approach for Ten PostgreSQL MCP tools are never registered with the host, so agents cannot call them #2659 is a sound way to make the six previously-unregistered tool classes fail loudly if this regresses.
  • WPF grid Grid.Row indices in ViewerServerTab.xaml and the new RowDefinitions line up correctly (0–6, no collision).
  • CI worker-sizing numbers (69→70, 80→81) are self-checked by CiClusterWorkerSizingTests, which derives its expectation from TimescaleSupport.HypertableCount at runtime rather than trusting the literal, so a mismatch would fail CI regardless of what I compute by hand.

One correctness edge case flagged inline on DarlingMcpPgServerStateTools.cs: get_pg_server_config's SQL-side LIMIT is applied before the C# include_defaults filter, which can silently drop and undercount non-default settings on heavily-overridden (e.g. Aurora/RDS) targets with more than limit (default 100) non-default GUCs.

No SQL injection, secrets, or missing-index-DMV concerns found. Didn't spot anything else worth raising.

Both statements carried a SESSION_SCOPED token substituted at call time, which
meant the constants were not SQL. DarlingPgReadSqlParsesLiveTests runs parse
analysis on every shipped PostgreSQL read against a real server and both failed
with 42703 column "session_scoped" does not exist.

The deduplication was not worth it. A read whose text only becomes valid after a
string replacement cannot be checked by anything -- not parse analysis, not a
reader, not a person. The excluded sources are now spelled out inline in both
statements, and PgServerConfigTests asserts each contains NOT IN ( plus the named
constant, so the two cannot drift now that they are written twice.

Verified by running PREPARE for both statements against a real PostgreSQL with
their actual parameter signatures -- the same thing CI does -- rather than by
rebuilding and hoping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +137 to +141
FROM pg_server_config AS c
WHERE c.server_id = $1
AND c.collection_time >= $2
AND c.collection_time <= $3
AND coalesce(c.source, '') NOT IN ('client', 'session', 'override')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: the time-range filter runs before LAG, so a genuine change at the start of the window is silently dropped.

LAG(...) OVER (PARTITION BY c.name ORDER BY c.collection_time) only sees rows that survived the WHERE c.collection_time >= $2 ... <= $3 filter in this same CTE. For each setting, the earliest row inside the window therefore always has prev_time IS NULL regardless of whether the value actually changed going into the window — its true predecessor (the last snapshot before $2) was filtered out before the window function ran. The prev_time IS NOT NULL guard then discards that row, so a real transition that happened right at (or just before) the window boundary is reported as "no change" instead of a change.

This isn't just a rare edge case: the collector runs hourly (CollectorScheduleDefaults["pg_server_config"] = new(60, 365)), and hours_back can legally be as low as 1 (McpHelpers.ValidateHoursBack). With a 1-hour window there is typically only one snapshot inside [$2, $3], so get_pg_server_config_changes will almost always report zero changes for short/recent windows — exactly the "what changed just now / this afternoon" query the tool exists to answer — even when a change genuinely occurred in that period.

Suggest computing LAG over the setting's full series (unfiltered by time) and applying the collection_time >= $2 AND <= $3 bounds in the outer SELECT ... FROM ordered WHERE ... instead of inside the CTE, so the row just before the window is still available to compare against. (If scanning the full retention window per query is a cost concern, padding the CTE's lower bound backward by one collection interval would also fix the boundary case without a full-history scan.)

Note: DarlingPgDatabaseReader/DarlingPgStatementReader/DarlingPgIoReader have the same filter-before-window shape, but there it just makes one interval's delta unmeasurable (GREATEST(raw, 0) → NULL, dropped from a SUM) — a minor undercount. Here the same shape drops a whole discrete change event from a change-history read, which is a materially different (and much more visible) failure mode for the specific promise this tool makes.

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review summary

Scope: adds PostgreSQL pg_settings collection (PgServerConfigCollector, V102 migration, DarlingPgServerConfigReader, two new MCP tools get_pg_server_config / get_pg_server_config_changes, web dashboard + WPF viewer wiring) and registers six previously-orphaned [McpServerToolType] classes (10 tools) with the MCP host. This is Darling-only — Lite has no PostgreSQL target seam at all (confirmed via Lite/Mcp/McpEngineCapability.cs), so there is no Lite counterpart owed here, and no parity drift.

Correctness bug found (left as an inline comment on DarlingPgServerConfigReader.cs): ConfigChangesSql filters collection_time to the requested window inside the CTE that computes LAG(...) OVER (PARTITION BY name ORDER BY collection_time). That means the earliest snapshot inside any window never has a same-window predecessor to compare against, so a real configuration change that lands at (or just before) the window's start boundary is silently reported as "no change." Because the collector runs hourly and hours_back can be as low as 1, this isn't a rare tail case — short/recent-window queries (the most natural use of a "what just changed" tool) will frequently see zero snapshots-with-predecessors in range and report no changes even when one occurred. None of the new tests (PgServerConfigTests, DarlingPgReadSqlParsesLiveTests) exercise this because they check SQL text/parse-validity, not query semantics across a window boundary.

Everything else checked out:

  • V102 migration schema-qualifies collect.pg_server_config per CONTRIBUTING's Darling rule, and is asserted byte-identical to the generated schema.
  • Session-scoped pg_settings rows (client/session/override) are stored (not dropped at collection) and filtered consistently at both reads, matching the documented rationale.
  • is_default correctly derives from PostgreSQL's own source rather than a setting/boot_val string comparison (the collector doc calls out concrete cases — data_directory_mode, archive_command, commit_timestamp_buffers — where the naive comparison would be wrong).
  • New MCP tools are registered in DarlingMcpHostService, dispatched in DarlingWebEndpoints, added to the web JS tab registry with a working format: "time" column, and covered by the new reflection-derived McpToolTypeRegistrationTests pin plus the updated tool-count/tab-count pins (116→128 tools, 7→8 Postgres tabs, catalog 66→67).
  • No SQL injection surface — both reads use positional ($1$4) bound parameters throughout; no string concatenation into query text.
  • PerformanceMonitor.Collectors catalog/engine-capability/schedule-default changes are additive and shared correctly with Lite via CrossAppMcpToolInventoryPinTests, which was updated for the two new tool names.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant