diff --git a/Darling/Darling.Tests/McpToolGuideHeads.PgB.cs b/Darling/Darling.Tests/McpToolGuideHeads.PgB.cs
new file mode 100644
index 0000000000..c057b55549
--- /dev/null
+++ b/Darling/Darling.Tests/McpToolGuideHeads.PgB.cs
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #3898 D3 head pins for the "pgB" family: get_pg_extensions, get_pg_write_stats,
+/// get_pg_server_config and get_pg_server_config_changes, all declared on
+/// DarlingMcpPgServerStateTools (Darling only - none of the four has a Lite twin). Follows the pattern in
+/// .
+///
+public sealed class McpToolGuideHeadsPgBTests
+{
+ private static readonly string[] ConvertedTools =
+ [
+ "get_pg_extensions",
+ "get_pg_write_stats",
+ "get_pg_server_config",
+ "get_pg_server_config_changes",
+ ];
+
+ /// The per-tool guardrail phrase each head must state.
+ private static readonly (string Tool, string Fact)[] HeadFacts =
+ [
+ ("get_pg_extensions", "Per-DATABASE, not per-cluster"),
+ ("get_pg_extensions", "truncated withholds state totals"),
+ ("get_pg_extensions", "installed rows like plpgsql are cut first"),
+ ("get_pg_extensions", "Read install_census for the complete picture"),
+ ("get_pg_write_stats", "as ONE row, not a series"),
+ ("get_pg_write_stats", "checkpoints_requested is the signal"),
+ ("get_pg_write_stats", "buffers_backend/buffers_backend_fsync are NULL on PostgreSQL 17+"),
+ ("get_pg_write_stats", "wal_* columns are NULL on Aurora"),
+ ("get_pg_write_stats", "A restart inside the window nulls checkpoints_requested"),
+ ("get_pg_server_config", "LATEST IS A TIME"),
+ ("get_pg_server_config", "captured_at is when it was taken"),
+ ("get_pg_server_config", "pending_restart=true means the file and server disagree"),
+ ("get_pg_server_config", "non_default_count is the snapshot's, not the page's"),
+ ("get_pg_server_config", "database_overrides (present only if any exist)"),
+ ("get_pg_server_config_changes", "NOT reported as a change"),
+ ("get_pg_server_config_changes", "Session-scoped rows are excluded"),
+ ("get_pg_server_config_changes", "change_kind: changed, set (old_value null) or reset (new_value null)"),
+ ];
+
+ [Fact]
+ public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
+ {
+ foreach (var tool in ConvertedTools)
+ {
+ var served = McpToolGuideTests.Served(tool);
+ Assert.NotNull(served.Tail);
+ Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
+ Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
+ Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
+ }
+
+ foreach (var (tool, fact) in HeadFacts)
+ {
+ Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
+ }
+ }
+
+ /// Nothing dropped: the parameter-overflow sentences D2 moved off get_pg_server_config's
+ /// include_defaults and get_pg_extensions' database_name land verbatim in their tool's
+ /// tail rather than disappearing when the parameter itself was trimmed to a guardrail sentence.
+ [Fact]
+ public void D2ParameterOverflow_LandsInTheTail_NotJustTheGuardrailSentence()
+ {
+ var serverConfig = McpToolGuideTests.Served("get_pg_server_config");
+ Assert.Contains("kept in the non-default view whatever its source", serverConfig.Tail!, StringComparison.Ordinal);
+
+ var extensions = McpToolGuideTests.Served("get_pg_extensions");
+ Assert.Contains("1,632 rows against the 1,000-row maximum", extensions.Tail!, StringComparison.Ordinal);
+ }
+
+ /// D8: no renames, no consolidation - all four still resolve as the same tool names with the same
+ /// parameters, just a shorter served head and the two over-cap parameters (D2) trimmed to a pointer.
+ [Fact]
+ public void OverCapParameters_StayAtOrUnder200_AndKeepThePointer()
+ {
+ foreach (var (tool, param) in new[] { ("get_pg_server_config", "include_defaults"), ("get_pg_extensions", "database_name") })
+ {
+ var served = McpToolGuideTests.Served(tool);
+ var p = Assert.Single(served.ParameterDescriptionLengths, x => x.Parameter == param);
+ Assert.True(p.Length <= 200, $"{tool}.{param}: {p.Length} > 200");
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgServerStateTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgServerStateTools.txt
index 8b19eb2e6f..d05e19910a 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgServerStateTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgServerStateTools.txt
@@ -5,9 +5,9 @@ param get_pg_buffer_usage.hours_back 40
param get_pg_buffer_usage.limit 35
param get_pg_buffer_usage.server_name 28
-tool get_pg_extensions 1727
+tool get_pg_extensions 498
param get_pg_extensions.as_of 167
-param get_pg_extensions.database_name 803
+param get_pg_extensions.database_name 196
param get_pg_extensions.hours_back 78
param get_pg_extensions.limit 35
param get_pg_extensions.server_name 28
@@ -18,18 +18,18 @@ param get_pg_lock_stats.hours_back 40
param get_pg_lock_stats.limit 35
param get_pg_lock_stats.server_name 28
-tool get_pg_server_config 2252
-param get_pg_server_config.include_defaults 249
+tool get_pg_server_config 612
+param get_pg_server_config.include_defaults 164
param get_pg_server_config.limit 198
param get_pg_server_config.server_name 28
-tool get_pg_server_config_changes 1146
+tool get_pg_server_config_changes 568
param get_pg_server_config_changes.as_of 167
param get_pg_server_config_changes.hours_back 52
param get_pg_server_config_changes.limit 39
param get_pg_server_config_changes.server_name 28
-tool get_pg_write_stats 1509
+tool get_pg_write_stats 598
param get_pg_write_stats.as_of 167
param get_pg_write_stats.hours_back 40
param get_pg_write_stats.server_name 28
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs
index cd08aa71de..00542eb0a3 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs
@@ -110,21 +110,13 @@ buffer COUNT is the measurement and the megabyte figure is a convenience beside
}
}
- [McpServerTool(Name = "get_pg_extensions"), Description("Gets which PostgreSQL extensions are installed, outdated, merely available, or absent, per database. Use this to answer why another read is empty: pg_stat_statements, pg_wait_sampling, pg_stat_kcache, pg_qualstats, pgstattuple, pg_buffercache and hypopg each back a specific tool, and 'absent' here is the reason that tool has nothing to show. State is one of installed, outdated, available or absent - 'available' means the files are on the server and CREATE EXTENSION would work, which is a different situation from absent and usually a one-line fix. IMPORTANT: extensions are per-DATABASE, so installed_version reflects the database the row names and not the cluster; an extension can be installed in the application database and absent from postgres. Rows are therefore the PRODUCT of databases and extension names - 102 extension names per database on the measured fleet - so a ten-database host needs 1,020 rows against a 1000-row maximum and cannot be completed in one call. Do NOT read a truncated row list as an install census: the ordering puts non-relevant 'installed' rows behind every non-relevant 'available' one, so plpgsql, which is installed in every database of every server, has no row at all once a host reaches ten databases. Read install_census instead - one row per extension created anywhere on the server with databases_installed against databases_total, aggregated so no row limit touches it - and pass database_name to complete one database's 102 rows at a time. The census and reach fields report the population and whether a larger limit would help; sampling this tool at a small limit returns exactly the 8 monitoring-relevant extensions per database and looks like a complete per-database answer.")]
+ [McpServerTool(Name = "get_pg_extensions"), Description("Gets which PostgreSQL extensions are installed, outdated, available or absent, per database, as of as_of (daily collector). Explains why another extension-backed tool is empty. Per-DATABASE, not per-cluster. Rows are databases x extension names, so a multi-database host overflows limit; truncated withholds state totals, and installed rows like plpgsql are cut first. Read install_census for the complete picture, or pass database_name for one database's full slice. <> Gets which PostgreSQL extensions are installed, outdated, merely available, or absent, per database. Use this to answer why another read is empty: pg_stat_statements, pg_wait_sampling, pg_stat_kcache, pg_qualstats, pgstattuple, pg_buffercache and hypopg each back a specific tool, and 'absent' here is the reason that tool has nothing to show. State is one of installed, outdated, available or absent - 'available' means the files are on the server and CREATE EXTENSION would work, which is a different situation from absent and usually a one-line fix. IMPORTANT: extensions are per-DATABASE, so installed_version reflects the database the row names and not the cluster; an extension can be installed in the application database and absent from postgres. Rows are therefore the PRODUCT of databases and extension names - 102 extension names per database on the measured fleet - so a ten-database host needs 1,020 rows against a 1000-row maximum and cannot be completed in one call. Do NOT read a truncated row list as an install census: the ordering puts non-relevant 'installed' rows behind every non-relevant 'available' one, so plpgsql, which is installed in every database of every server, has no row at all once a host reaches ten databases. Read install_census instead - one row per extension created anywhere on the server with databases_installed against databases_total, aggregated so no row limit touches it - and pass database_name to complete one database's 102 rows at a time. The census and reach fields report the population and whether a larger limit would help; sampling this tool at a small limit returns exactly the 8 monitoring-relevant extensions per database and looks like a complete per-database answer. A sixteen-database host's product is 1,632 rows against the 1,000-row maximum; database_name completes one database at a time instead, sixteen calls that each finish.")]
public static async Task GetPgExtensions(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history to analyze. Default 168 (7 days) - this collector runs daily.")] int hours_back = 168,
[Description("Maximum rows to return. Default 50.")] int limit = 50,
- [Description("One database, or omit for every database on the server. Rows are one per (database, "
- + "extension), so the row count is databases x extension names - 102 extension names per database "
- + "on the measured fleet, which puts a ten-database host past the 1000-row maximum and a "
- + "sixteen-database one at 1,632. ONE database's slice is 102 rows, so this is what makes such a "
- + "host completable: sixteen calls that each finish instead of one that cannot. OMIT it for an "
- + "install census: install_census is aggregated per extension and no row limit touches it, so "
- + "one unfiltered call answers what is installed where. It follows this filter when you supply "
- + "one, so a filtered response describes that database and nothing else - census, install_census "
- + "and reach all share the rows' scope, and the echoed database_name names it.")]
+ [Description("One database, or omit for every database. Rows are (database x extension), so a multi-database host can exceed limit. Pass this to complete one database's full slice. See the tool's reading guide.")]
string? database_name = null,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
@@ -474,7 +466,7 @@ of presence rather than an unanchored count. */
}
}
- [McpServerTool(Name = "get_pg_write_stats"), Description("Gets PostgreSQL checkpoint and WAL write activity across the window: how many checkpoints were timed versus REQUESTED, how long they spent writing and syncing, how many buffers were written by checkpoints, by the background writer and by backends themselves, and the WAL record, full-page-image and byte totals. Requested checkpoints are the signal to look for - a timed checkpoint is the scheduled one, while a requested checkpoint means WAL filled max_wal_size before the interval elapsed, so a high requested share means checkpoints are being forced by write volume. buffers_backend counts writes a query had to do itself because no clean buffer was available, which is backpressure landing on user queries - PostgreSQL 17 removed that column from pg_stat_bgwriter, so it is null on 17 and later and the note says so. wal_fpi counts full-page images, which is why write volume spikes immediately after each checkpoint. A restart inside the window leaves checkpoints_requested, pct_checkpoints_requested, checkpoint_write_time_ms, checkpoint_sync_time_ms and buffers_written_checkpoint null, with postmaster_restarted true: PostgreSQL counts a shutdown checkpoint as requested and keeps the count across the restart, and that checkpoint's own write and sync work lands in the same counters, so across one those figures cannot be told from the workload's (postmaster_start_time says when the server last started, so a window after it can be chosen). Returns one row describing the whole window, not a series.")]
+ [McpServerTool(Name = "get_pg_write_stats"), Description("Gets PostgreSQL checkpoint and WAL activity for the window ending at as_of, as ONE row, not a series. checkpoints_requested is the signal: a high share means max_wal_size filled before the interval, forcing checkpoints. buffers_backend/buffers_backend_fsync are NULL on PostgreSQL 17+ (moved to pg_stat_io; see get_pg_io_stats), not unmeasured. wal_* columns are NULL on Aurora (no pg_stat_wal); get_pg_top_queries has per-statement WAL. A restart inside the window nulls checkpoints_requested, its percent, both checkpoint time fields and buffers_written_checkpoint. <> Gets PostgreSQL checkpoint and WAL write activity across the window: how many checkpoints were timed versus REQUESTED, how long they spent writing and syncing, how many buffers were written by checkpoints, by the background writer and by backends themselves, and the WAL record, full-page-image and byte totals. Requested checkpoints are the signal to look for - a timed checkpoint is the scheduled one, while a requested checkpoint means WAL filled max_wal_size before the interval elapsed, so a high requested share means checkpoints are being forced by write volume. buffers_backend counts writes a query had to do itself because no clean buffer was available, which is backpressure landing on user queries - PostgreSQL 17 removed that column from pg_stat_bgwriter, so it is null on 17 and later and the note says so. wal_fpi counts full-page images, which is why write volume spikes immediately after each checkpoint. A restart inside the window leaves checkpoints_requested, pct_checkpoints_requested, checkpoint_write_time_ms, checkpoint_sync_time_ms and buffers_written_checkpoint null, with postmaster_restarted true: PostgreSQL counts a shutdown checkpoint as requested and keeps the count across the restart, and that checkpoint's own write and sync work lands in the same counters, so across one those figures cannot be told from the workload's (postmaster_start_time says when the server last started, so a window after it can be chosen). Returns one row describing the whole window, not a series.")]
public static async Task GetPgWriteStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -624,12 +616,12 @@ null. postmaster_start_time is the window's last sample's. */
}
}
- [McpServerTool(Name = "get_pg_server_config"), Description("Gets the PostgreSQL server's configuration from pg_settings - what each parameter is set to, whether it differs from the compiled-in default, where the value came from (configuration file, command line, ALTER SYSTEM, per-database or per-role), and whether changing it needs a restart or only a reload. Non-default settings are listed FIRST, because a server has several hundred parameters and only the ones somebody chose are an answer. Reports pending_restart loudly: that means postgresql.conf was edited and reloaded but the running server is still using the old value, so the file and the server disagree with no symptom until the next restart. Session-scoped rows are excluded - pg_settings is a per-connection view and its client-source rows describe the monitoring connection, not the server. LATEST IS A TIME: this is the newest stored snapshot, not a window and not the live server - captured_at is the instant it was taken. The collector runs hourly, so a value here is 'as of' that stamp: a setting changed since (ALTER SYSTEM, a reload, a parameter-group edit) is not reflected until the next collection, and on a server whose collector has stalled the stamp is the only thing that says how stale the answer is. Compare captured_at against get_collection_log before trusting a value in an incident. THE PAGE IS BOUNDED BY limit: settings_returned is how many rows you got, truncated says the population you asked for (the non-default settings, or every setting when include_defaults is true) held more, and the rows are the chosen ones first. COUNTS ARE OF THE SNAPSHOT, NOT OF THE PAGE: non_default_count is how many settings in the whole snapshot differ from their default, computed in the same statement as the rows before the cap, so it is the same number at any limit; non_default_returned is how many of those are on this page, and the gap between the two is what the cap left out. PER-DATABASE AND PER-ROLE OVERRIDES ARE A SEPARATE SECTION: the settings list is the SERVER's configuration, and database_overrides - present only when the cluster has any - carries the values one database or one role was given with ALTER DATABASE/ROLE SET, which are what sessions there actually run with rather than the server-wide value beside them.")]
+ [McpServerTool(Name = "get_pg_server_config"), Description("Gets the PostgreSQL server's configuration from pg_settings: value, default, source, restart-or-reload need. LATEST IS A TIME: the newest stored snapshot, not a window or the live server; captured_at is when it was taken (collector runs hourly). Non-default settings list FIRST; pass include_defaults for the rest. pending_restart=true means the file and server disagree, with no other symptom. Bounded by limit (truncated says if more existed); non_default_count is the snapshot's, not the page's. database_overrides (present only if any exist) is what sessions actually run with. <> Gets the PostgreSQL server's configuration from pg_settings - what each parameter is set to, whether it differs from the compiled-in default, where the value came from (configuration file, command line, ALTER SYSTEM, per-database or per-role), and whether changing it needs a restart or only a reload. Non-default settings are listed FIRST, because a server has several hundred parameters and only the ones somebody chose are an answer. Reports pending_restart loudly: that means postgresql.conf was edited and reloaded but the running server is still using the old value, so the file and the server disagree with no symptom until the next restart. Session-scoped rows are excluded - pg_settings is a per-connection view and its client-source rows describe the monitoring connection, not the server. LATEST IS A TIME: this is the newest stored snapshot, not a window and not the live server - captured_at is the instant it was taken. The collector runs hourly, so a value here is 'as of' that stamp: a setting changed since (ALTER SYSTEM, a reload, a parameter-group edit) is not reflected until the next collection, and on a server whose collector has stalled the stamp is the only thing that says how stale the answer is. Compare captured_at against get_collection_log before trusting a value in an incident. THE PAGE IS BOUNDED BY limit: settings_returned is how many rows you got, truncated says the population you asked for (the non-default settings, or every setting when include_defaults is true) held more, and the rows are the chosen ones first. COUNTS ARE OF THE SNAPSHOT, NOT OF THE PAGE: non_default_count is how many settings in the whole snapshot differ from their default, computed in the same statement as the rows before the cap, so it is the same number at any limit; non_default_returned is how many of those are on this page, and the gap between the two is what the cap left out. PER-DATABASE AND PER-ROLE OVERRIDES ARE A SEPARATE SECTION: the settings list is the SERVER's configuration, and database_overrides - present only when the cluster has any - carries the values one database or one role was given with ALTER DATABASE/ROLE SET, which are what sessions there actually run with rather than the server-wide value beside them. A setting marked pending_restart is kept in the non-default view whatever its source, because it is the row that says the file and the server disagree.")]
public static async Task GetPgServerConfig(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Maximum settings to return. Default 100. This is what bounds the page - read truncated to know whether the population held more; non_default_count stays the whole snapshot's whatever this is set to.")] int limit = 100,
- [Description("When true, include settings still at their default. Default false - the non-default ones are the answer, and a setting marked pending_restart is kept in that view whatever its source, because it is the row that says the file and the server disagree.")] bool include_defaults = false)
+ [Description("When true, include settings still at their default. Default false: non-default settings, plus any pending_restart row, are the answer. See the tool's reading guide.")] bool include_defaults = false)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
if (error != null) return error;
@@ -773,7 +765,7 @@ excludes it. */
}
}
- [McpServerTool(Name = "get_pg_server_config_changes"), Description("Gets PostgreSQL configuration parameters whose value CHANGED during the window, newest first, with the old and new value side by side. This is the read that answers 'this got slow sometime last month, what changed' - and nothing else in the stack can reconstruct it after the fact, because a configuration history that was not recorded cannot be recovered from the server. A setting appearing for the first time is deliberately NOT reported as a change: the first snapshot after an upgrade, or after an extension is loaded, would otherwise manufacture hundreds of changes nobody made. Session-scoped rows are excluded, so a monitoring reconnect does not read as a configuration change. PER-DATABASE AND PER-ROLE OVERRIDES (ALTER DATABASE/ROLE SET) are reported too, interleaved with the server-wide rows by changed_at: such a row carries database_name and/or role_name (only the ones it is scoped to) and change_kind - changed, set (old_value null: the override appeared) or reset (new_value null: it was removed) - while a server-wide row carries neither, and overrides already present when the collector first read them are not reported as set.")]
+ [McpServerTool(Name = "get_pg_server_config_changes"), Description("Gets PostgreSQL configuration parameters whose value CHANGED in the window ending at as_of, newest first, old and new value side by side. Nothing else can reconstruct this after the fact. A setting appearing for the FIRST time (an upgrade, a new extension) is NOT reported as a change. Session-scoped rows are excluded. Per-database/per-role overrides (ALTER DATABASE/ROLE SET) interleave by changed_at with change_kind: changed, set (old_value null) or reset (new_value null); one already present when first read is not reported as set. <> Gets PostgreSQL configuration parameters whose value CHANGED during the window, newest first, with the old and new value side by side. This is the read that answers 'this got slow sometime last month, what changed' - and nothing else in the stack can reconstruct it after the fact, because a configuration history that was not recorded cannot be recovered from the server. A setting appearing for the first time is deliberately NOT reported as a change: the first snapshot after an upgrade, or after an extension is loaded, would otherwise manufacture hundreds of changes nobody made. Session-scoped rows are excluded, so a monitoring reconnect does not read as a configuration change. PER-DATABASE AND PER-ROLE OVERRIDES (ALTER DATABASE/ROLE SET) are reported too, interleaved with the server-wide rows by changed_at: such a row carries database_name and/or role_name (only the ones it is scoped to) and change_kind - changed, set (old_value null: the override appeared) or reset (new_value null: it was removed) - while a server-wide row carries neither, and overrides already present when the collector first read them are not reported as set.")]
public static async Task GetPgServerConfigChanges(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,