diff --git a/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs b/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs
index 07893a914..f477bca73 100644
--- a/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs
+++ b/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs
@@ -137,32 +137,57 @@ await File.WriteAllTextAsync(configPath, $$"""
public async Task CheckSettingsAsync_ManagedStoreWithAStaleBlock_ReturnsStaleSettingsExitCode_Gated()
{
var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
- var runtimeRoot = Environment.GetEnvironmentVariable("DARLING_TEST_PGRUNTIME");
Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
"Set DARLING_TEST_PG to run the live --check-settings exit-code tests.");
- Assert.SkipWhen(string.IsNullOrWhiteSpace(runtimeRoot),
- "Set DARLING_TEST_PGRUNTIME to the rig's runtime root — this drives --check-settings through a "
- + "REAL managed data directory, which DARLING_TEST_PG alone does not locate on disk.");
-
- var dataDirectory = Path.Combine(runtimeRoot!, "data");
- var confPath = Path.Combine(dataDirectory, "postgresql.conf");
- Assert.SkipUnless(File.Exists(confPath), $"DARLING_TEST_PGRUNTIME={runtimeRoot} has no data\\postgresql.conf.");
+ var ct = TestContext.Current.CancellationToken;
var builder = new NpgsqlConnectionStringBuilder(connectionString);
+
+ /* The "managed" connect path (DarlingManagedPostgres.TryBuildConnectionStringFromStoredCredential)
+ always targets DarlingManagedPostgres.DatabaseName ("darling") — hardcoded, not read from config —
+ so this test provisions that database itself rather than assuming a hand-built rig already has it.
+ A rig built per the lane's own setup only carries the suite's own database and a scratch database
+ (never "darling"), and asking --check-settings to connect against a database that does not exist
+ fails at StoreUnreachable before it ever reaches a settings verdict — the defect this rewrite fixes
+ (it used to point straight at DARLING_TEST_PGRUNTIME's own data directory and assume "darling"
+ already existed there, which is true of a bootstrapped product install and false of a bare rig). */
+ var adminBuilder = new NpgsqlConnectionStringBuilder(connectionString) { Database = "postgres" };
+ var createdDarlingDatabase = false;
+ await using (var admin = new NpgsqlConnection(adminBuilder.ConnectionString))
+ {
+ await admin.OpenAsync(ct);
+ await using var probe = new NpgsqlCommand(
+ "SELECT 1 FROM pg_database WHERE datname = 'darling'", admin);
+ if (await probe.ExecuteScalarAsync(ct) is null)
+ {
+ await using var create = new NpgsqlCommand("CREATE DATABASE darling", admin);
+ await create.ExecuteNonQueryAsync(ct);
+ createdDarlingDatabase = true;
+ }
+ }
+
+ /* A THROWAWAY data directory, never the rig's real one (ruling 7: a live test here must not depend on
+ — or mutate — the rig's own postgresql.conf). AttributeManagedSetting reads the conf file straight
+ off disk; it has no dependency on the directory actually being what PostgreSQL was started from, so
+ a directory holding nothing but a hand-built postgresql.conf is exactly as real an input to it as
+ the rig's own. */
var root = Directory.CreateTempSubdirectory("darling-checksettings-stale-");
- var originalConf = File.ReadAllText(confPath);
try
{
+ var dataDirectory = Path.Combine(root.FullName, "data");
+ Directory.CreateDirectory(dataDirectory);
+
/* max_connections = 100 inside a managed (v4) block: PostgreSQL's own untouched default already
disagrees with today's derivation (DarlingManagedPostgres.TargetMaxConnections = 200), and
ClassifyVerdict compares the LIVE value against TODAY's derivation — no reload needed. */
- File.AppendAllText(confPath, "\n" + DarlingManagedPostgres.ConfMarkerV4 + "\nmax_connections = 100\n");
+ await File.WriteAllTextAsync(
+ Path.Combine(dataDirectory, "postgresql.conf"),
+ "\n" + DarlingManagedPostgres.ConfMarkerV4 + "\nmax_connections = 100\n", ct);
- /* The owner credential the "managed" connect path reads (DarlingManagedPostgres.
- TryBuildConnectionStringFromStoredCredential) — the rig trusts any password (initdb -A trust),
- so the protected value's content does not have to be the rig's real (nonexistent) password. */
+ /* The owner credential the "managed" connect path reads — the rig trusts any password (initdb
+ -A trust), so the protected value's content does not have to be the rig's real password. */
var credentialPath = DarlingManagedPostgres.CredentialPathFor(dataDirectory);
- await File.WriteAllTextAsync(credentialPath, DarlingSecrets.Protect("trust-auth-ignores-this"));
+ await File.WriteAllTextAsync(credentialPath, DarlingSecrets.Protect("trust-auth-ignores-this"), ct);
var configPath = Path.Combine(root.FullName, "darling.json");
await File.WriteAllTextAsync(configPath, $$"""
@@ -170,19 +195,29 @@ await File.WriteAllTextAsync(configPath, $$"""
"postgres": { "managed": true, "port": {{builder.Port}}, "dataDirectory": {{JsonSerializer.Serialize(dataDirectory)}} },
"servers": [ { "name": "SQL2022", "host": "SQL2022" } ]
}
- """);
+ """, ct);
var output = new StringWriter();
var error = new StringWriter();
- var exit = await DarlingCliCommands.CheckSettingsAsync(configPath, false, output, error, CancellationToken.None);
+ var exit = await DarlingCliCommands.CheckSettingsAsync(configPath, false, output, error, ct);
Assert.Equal(DarlingCliCommands.CheckSettingsExitCode.StaleSettings, exit);
Assert.Contains("stale-after-hardware-change", output.ToString(), StringComparison.Ordinal);
}
finally
{
- File.WriteAllText(confPath, originalConf);
Directory.Delete(root.FullName, recursive: true);
+
+ if (createdDarlingDatabase)
+ {
+ await using var admin = new NpgsqlConnection(adminBuilder.ConnectionString);
+ await admin.OpenAsync(ct);
+ await using var terminate = new NpgsqlCommand(
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'darling' AND pid <> pg_backend_pid()", admin);
+ await terminate.ExecuteNonQueryAsync(ct);
+ await using var drop = new NpgsqlCommand("DROP DATABASE IF EXISTS darling", admin);
+ await drop.ExecuteNonQueryAsync(ct);
+ }
}
}
diff --git a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs
index 99795bbf0..87621482f 100644
--- a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs
@@ -249,7 +249,9 @@ public void TheMirrorsLoggerSeat_TakesTheCapturedServiceLogger_NotAHardcodedNull
/* The entry passes the captured logger, and MapAll is the caller that supplies it. */
Assert.Contains("DarlingMcpFleetSweepTools.GetSweepReports(pg, logger,", source, StringComparison.Ordinal);
- Assert.Contains("BuildReadDispatch(logger)", source, StringComparison.Ordinal);
+ /* #4214 part 2: BuildReadDispatch also threads postgresConfig (get_store_host's own config seat),
+ so the literal grew a second argument; still the same logger-by-closure call MapAll makes. */
+ Assert.Contains("BuildReadDispatch(logger, postgresConfig)", source, StringComparison.Ordinal);
/* The delegate itself did NOT grow a seat — the ~100-entry table stays three-parameter,
which is the whole reason the logger rides by closure. */
diff --git a/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs
new file mode 100644
index 000000000..ef2fc1cab
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs
@@ -0,0 +1,64 @@
+/*
+ * 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 System.Text;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4214 part 2 / #4198: get_store_host's own response-budget pin. Unlike the row-scaling #4198 tools
+/// (get_query_store_regressions, get_blocking, ...), this tool's payload is a FIXED shape - platform/ram/
+/// data_volume/store facts plus exactly one row per sizing-relevant setting (eight today) - so there is no
+/// "plant N rows" scale knob; a single live call against the shared rig is the whole proof.
+///
+/// Measured against the not_managed (bring-your-own) shape deliberately: every setting's
+/// source text is the longest of the four verdicts' strings ("not-managed (bring-your-own store;
+/// pg_settings.source = ...)"), so it is the more conservative of the two shapes for a byte count, not the
+/// managed one a sized store would actually return.
+///
+[Collection("live-postgres")]
+public sealed class DarlingMcpStoreHostBudgetLiveTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public DarlingMcpStoreHostBudgetLiveTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ public async Task GetStoreHost_BringYourOwnShape_StaysUnderResponseBudget()
+ {
+ var cs = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the get_store_host budget test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ await using (var connection = new NpgsqlConnection(cs))
+ {
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ /* A fresh cache per test (round-1 review, Medium 2), not the production Shared singleton — this
+ test's budget measurement must always hit the live gather, never a hit left warm by another test
+ in the same process. */
+ var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5));
+ var json = await DarlingMcpStoreHostTools.GetStoreHost(postgres, new PostgresConfig { Managed = false }, cache);
+
+ var bytes = Encoding.UTF8.GetByteCount(json);
+ _output.WriteLine($"get_store_host (not_managed) call: {bytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}).");
+ Assert.True(bytes < McpResponseBudget.DefaultBytes,
+ $"get_store_host is {bytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+ }
+}
diff --git a/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs b/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs
new file mode 100644
index 000000000..7be0915ec
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs
@@ -0,0 +1,228 @@
+/*
+ * 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 System.IO;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// get_store_host (#4214 part 2) read through the SAME least-privilege roles it runs under in
+/// production — mcp (DarlingMcpHostService's own connection role) and viewer (the web
+/// host's /api/read connection role, see TryBuildViewerConnectionStringFromStoredCredential)
+/// — never the rig's owner/superuser every other #4214 part 2 test (including this file's siblings) connects
+/// as. Proves all four verdicts (matches, stale_after_hardware_change, operator_override,
+/// not_managed) are reachable end to end under EACH role, so a missing grant would surface as a failed
+/// row assertion here rather than as a silent gap nobody caught before a real least-privilege deployment hit
+/// it.
+///
+/// Uses distinct host_mcp_test/host_viewer_test roles (the
+/// DarlingSecuritySplitLiveTests pattern) rather than the literal mcp/viewer names
+/// DarlingManagedRoles.BuildProvisioningSql renders, so this test can run against a shared rig without
+/// colliding with a real provisioning run. Granted the schema-level surface that provisioning gives the real
+/// roles (USAGE + SELECT on collect, load-bearing for timescaledb_information.chunks
+/// visibility — a role with no SELECT on a hypertable sees none of its chunks in that view). The other three
+/// reads GatherStoreFactsAsync/GatherSettingProfilesAsync make — pg_extension,
+/// pg_stat_database, pg_settings — carry no Darling-authored GRANT anywhere in
+/// DarlingManagedRoles; PostgreSQL's own PUBLIC defaults are what let a least-privilege role read them
+/// at all, and this test is what actually proves that assumption live rather than trusting it.
+///
+[Collection("live-postgres")]
+public sealed class DarlingMcpStoreHostToolsLiveTests
+{
+ private const string McpRole = "host_mcp_test";
+ private const string ViewerRole = "host_viewer_test";
+
+ /// Round-1 review, Low 6: a hardcoded password here is public in this repository, and each of
+ /// these two LOGIN roles gets SELECT on every table in collect. Cleanup runs in finally, but a
+ /// cleanup failure after a failed body is swallowed (LiveStoreCleanup), and a killed run skips
+ /// cleanup outright — generating a fresh password per run means a role that outlives this test is not a
+ /// known, reusable credential. Hex output is safe to interpolate directly into the DDL string below (no
+ /// quoting characters). DarlingSecuritySplitLiveTests uses the same hardcoded-password pattern but is
+ /// explicitly out of scope here.
+ private static readonly string RolePassword = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
+
+ [Fact]
+ public async Task GetStoreHost_AsMcpAndViewerRoles_SurfacesAllFourVerdicts_Live()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to run the get_store_host least-privilege live tests.");
+
+ var ct = TestContext.Current.CancellationToken;
+ await using var owner = new NpgsqlConnection(connectionString);
+ await owner.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(owner, ct);
+ await CreateTestRolesAsync(owner, ct);
+
+ var bodySucceeded = false;
+ var root = Directory.CreateTempSubdirectory("darling-storehost-roles-");
+ try
+ {
+ var managedDir = Path.Combine(root.FullName, "managed");
+ Directory.CreateDirectory(managedDir);
+
+ /* shared_buffers is PGC_POSTMASTER (restart-only) and max_wal_size/max_connections' LIVE value
+ comes from the REAL rig server this connects to, never from this fake data directory — the fake
+ conf only drives AttributeManagedSetting's FILE attribution (which marker/block a name falls
+ in), not the live comparison value. So "matches" is built the one way a live value CAN be set
+ for a fresh session with no restart and no ALTER SYSTEM (which would misattribute to
+ operator_override via postgresql.auto.conf): work_mem is PGC_USERSET, so a per-ROLE default
+ (ALTER ROLE ... SET) applies at the next session start with no reload at all. max_connections
+ (PGC_POSTMASTER, TargetMaxConnections = 200 - a host-independent constant) is left at whatever
+ this rig already booted with, which DarlingCliCommandsHostCheckTests already proves live is
+ never 200 -> stale_after_hardware_change. maintenance_work_mem is left unassigned in the fake
+ conf entirely -> ClassifyVerdict's default arm, operator_override. One conf, three verdicts. */
+ var (_, _, _, memory) = DarlingStoreHostProfile.GatherHostFacts();
+ var derivedWorkMemMb = DarlingManagedPostgres.DeriveMemorySettings(
+ DarlingManagedPostgres.QuantizeRam(memory.EffectiveBytes)).WorkMemMb;
+
+ File.WriteAllText(Path.Combine(managedDir, "postgresql.conf"),
+ DarlingManagedPostgres.ConfMarkerV4 + "\n"
+ + "work_mem = 999MB\n"
+ + "max_connections = 100\n");
+
+ var managedConfig = new PostgresConfig { Managed = true, DataDirectory = managedDir };
+ var byoConfig = new PostgresConfig { Managed = false };
+
+ foreach (var role in new[] { McpRole, ViewerRole })
+ {
+ /* A per-role default, applied at the NEXT session's start — must run before this role's data
+ source opens its first physical connection, which is what makes the freshly-derived value
+ visible to GetStoreHost's own live pg_settings read below. */
+ await using (var setDefault = new NpgsqlCommand(
+ $"ALTER ROLE {role} SET work_mem = '{derivedWorkMemMb}MB'", owner))
+ {
+ await setDefault.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var dataSource = NpgsqlDataSource.Create(RoleConnectionString(connectionString!, role));
+
+ /* A fresh cache per call, not one shared across this loop (round-1 review, Medium 2): the
+ cache holds a single un-keyed entry (production only ever has one store to profile), so
+ sharing one instance across the managed and BYO calls below would serve the FIRST call's
+ cached profile back for the second, regardless of which PostgresConfig was passed. */
+ var managedJson = await DarlingMcpStoreHostTools.GetStoreHost(
+ dataSource, managedConfig, new StoreHostProfileCache(TimeSpan.FromMinutes(5)));
+ using var managed = JsonDocument.Parse(managedJson);
+ AssertVerdictFor(role, managed, "work_mem", "matches");
+ AssertVerdictFor(role, managed, "max_connections", "stale_after_hardware_change");
+ AssertVerdictFor(role, managed, "maintenance_work_mem", "operator_override");
+ Assert.True(managed.RootElement.GetProperty("any_stale").GetBoolean(),
+ $"{role}: any_stale should be true with a stale_after_hardware_change row present.");
+
+ /* store facts, under the least-privilege role (item 5): size_bytes and timescale_version
+ both come off PostgreSQL/TimescaleDB reads PUBLIC can run with no Darling-authored GRANT
+ (this file's own class doc comment) — a regression there is a silent "unavailable" null,
+ not a thrown exception, so only an explicit not-null assertion catches it. */
+ var store = managed.RootElement.GetProperty("store");
+ Assert.NotEqual(JsonValueKind.Null, store.GetProperty("size_bytes").ValueKind);
+ Assert.NotEqual(JsonValueKind.Null, store.GetProperty("timescale_version").ValueKind);
+
+ /* uncompressed_chunk_count against a fresh OWNER-side read of the exact same query
+ (DarlingStoreHostProfile.UncompressedChunkSizeSql, ruling 1 — one formula, not a second
+ copy here) taken immediately after the tool call, under the role that actually granted
+ the schema surface (this file's whole point) rather than trusting the row count alone. */
+ long ownerUncompressedChunkCount;
+ await using (var chunkCmd = new NpgsqlCommand(DarlingStoreHostProfile.UncompressedChunkSizeSql, owner))
+ await using (var chunkReader = await chunkCmd.ExecuteReaderAsync(ct))
+ {
+ await chunkReader.ReadAsync(ct);
+ ownerUncompressedChunkCount = chunkReader.GetInt32(1);
+ }
+
+ Assert.Equal(ownerUncompressedChunkCount, store.GetProperty("uncompressed_chunk_count").GetInt32());
+
+ var byoJson = await DarlingMcpStoreHostTools.GetStoreHost(
+ dataSource, byoConfig, new StoreHostProfileCache(TimeSpan.FromMinutes(5)));
+ using var byo = JsonDocument.Parse(byoJson);
+ AssertVerdictFor(role, byo, "work_mem", "not_managed");
+ AssertVerdictFor(role, byo, "max_connections", "not_managed");
+ Assert.False(byo.RootElement.GetProperty("any_stale").GetBoolean(),
+ $"{role}: a bring-your-own gather must never report any_stale.");
+ }
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ Directory.Delete(root.FullName, recursive: true);
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ {
+ await DropTestRolesAsync(cleanup, cleanupCt);
+ });
+ }
+ }
+
+ private static void AssertVerdictFor(string role, JsonDocument doc, string settingName, string expectedVerdict)
+ {
+ foreach (var row in doc.RootElement.GetProperty("settings").EnumerateArray())
+ {
+ if (row.GetProperty("name").GetString() == settingName)
+ {
+ Assert.True(expectedVerdict == row.GetProperty("verdict").GetString(),
+ $"{role}/{settingName}: expected verdict '{expectedVerdict}' but get_store_host returned "
+ + $"'{row.GetProperty("verdict").GetString()}' (source: {row.GetProperty("source").GetString()}).");
+ return;
+ }
+ }
+
+ Assert.Fail($"{role}: get_store_host returned no '{settingName}' row.");
+ }
+
+ /// Mirrors DarlingSecuritySplitLiveTests.CreateTestRolesAndGrantsAsync's shape, narrowed to
+ /// what get_store_host actually reads: no config-schema grant, no write grant — this tool only ever
+ /// SELECTs.
+ private static async Task CreateTestRolesAsync(NpgsqlConnection owner, System.Threading.CancellationToken ct)
+ {
+ var ddl = $@"
+DO $do$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{McpRole}') THEN
+ CREATE ROLE {McpRole} LOGIN NOSUPERUSER PASSWORD '{RolePassword}';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{ViewerRole}') THEN
+ CREATE ROLE {ViewerRole} LOGIN NOSUPERUSER PASSWORD '{RolePassword}';
+ END IF;
+END $do$;
+GRANT USAGE ON SCHEMA collect TO {McpRole}, {ViewerRole};
+GRANT SELECT ON ALL TABLES IN SCHEMA collect TO {McpRole}, {ViewerRole};
+ALTER DEFAULT PRIVILEGES FOR ROLE {OwnerRoleOf(owner)} IN SCHEMA collect GRANT SELECT ON TABLES TO {McpRole}, {ViewerRole};";
+ await using var cmd = new NpgsqlCommand(ddl, owner);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ /// ALTER DEFAULT PRIVILEGES keys on the role that CREATEs the object — here the connected owner.
+ private static string OwnerRoleOf(NpgsqlConnection owner)
+ => new NpgsqlConnectionStringBuilder(owner.ConnectionString).Username ?? "darling";
+
+ private static async Task DropTestRolesAsync(NpgsqlConnection owner, System.Threading.CancellationToken ct)
+ => await new LiveCleanupBatch(owner).DropRolesAsync(
+ $@"
+DROP OWNED BY {McpRole}, {ViewerRole};
+DROP ROLE IF EXISTS {McpRole};
+DROP ROLE IF EXISTS {ViewerRole};",
+ [McpRole, ViewerRole],
+ ct);
+
+ private static string RoleConnectionString(string baseConnectionString, string role)
+ => new NpgsqlConnectionStringBuilder(baseConnectionString)
+ {
+ Username = role,
+ Password = RolePassword,
+ SearchPath = "collect,public",
+ }.ConnectionString;
+}
diff --git a/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs b/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs
index 1d7ff1583..0b0f05c87 100644
--- a/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs
+++ b/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.IO;
using PerformanceMonitor.Darling.Service;
using Xunit;
@@ -195,6 +196,91 @@ public void ClassifyVerdict_UnsetFallsBackToOperatorOverride()
Assert.Equal(HostSettingVerdict.OperatorOverride, verdict);
}
+ /* ------------------------------------- FormatSourceForMcp (round-1 review, Medium 1) ------------------------------------- */
+ /* Three shapes: a file inside the managed data directory redacts to a directory-relative path, a file
+ outside it (an include elsewhere) redacts to the bare file name only, and anything with no SourceFile
+ (not-managed / unreadable / not-visible) passes SourceDescription through unchanged — it is already a
+ kind word, never a path. */
+
+ [Fact]
+ public void FormatSourceForMcp_FileInsideDataDirectory_ReturnsRelativePathNoParentNoRoot()
+ {
+ var dataDirectory = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata");
+ var confFile = Path.Combine(dataDirectory, "postgresql.conf");
+ var setting = new HostSettingProfile(
+ "shared_buffers", "1024MB", 1024, "unused-when-sourcefile-set", "1024MB", 1024,
+ HostSettingVerdict.Matches, confFile, 42);
+
+ var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory);
+
+ Assert.Equal("managed block (postgresql.conf:42)", source);
+ Assert.DoesNotContain("..", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(dataDirectory, source, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FormatSourceForMcp_FileOutsideDataDirectory_ReturnsBareFileNameOnly()
+ {
+ var dataDirectory = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata");
+ var includedFile = Path.Combine(Path.GetTempPath(), "pmdarling-test-elsewhere", "included.conf");
+ var setting = new HostSettingProfile(
+ "work_mem", "4MB", 4, "unused-when-sourcefile-set", "4MB", 4,
+ HostSettingVerdict.OperatorOverride, includedFile, 7);
+
+ var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory);
+
+ Assert.Equal("operator override (included.conf:7)", source);
+ Assert.DoesNotContain(dataDirectory, source, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain(Path.DirectorySeparatorChar.ToString(), source, StringComparison.Ordinal);
+ Assert.DoesNotContain(Path.AltDirectorySeparatorChar.ToString(), source, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(HostSettingVerdict.NotManaged, "not-managed (bring-your-own store; pg_settings.source = configuration file)")]
+ [InlineData(HostSettingVerdict.NotManaged, "unreadable — this connection's role, or this build of TimescaleDB, does not expose it")]
+ public void FormatSourceForMcp_NoSourceFile_ReturnsSourceDescriptionVerbatim(HostSettingVerdict verdict, string sourceDescription)
+ {
+ var setting = new HostSettingProfile(
+ "max_connections", "100", 100, sourceDescription, "100", 100, verdict);
+
+ var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory: null);
+
+ Assert.Equal(sourceDescription, source);
+ }
+
+ /* --------------------------------------- TryResolveProfileDataDirectory / UNC refusal (round-1 review, Low 4) --------------------------------------- */
+ /* A UNC-configured managed data directory is refused rather than reached over the network as the service's
+ own identity (the NTLM-relay vector Low 4 describes) — mirrors DarlingManagedPostgres.TryResolveConfPath's
+ own UNC refusal for an include directive. */
+
+ [Fact]
+ public void TryResolveProfileDataDirectory_UncDataDirectory_ReturnsNull()
+ {
+ var postgres = new PostgresConfig { Managed = true, DataDirectory = @"\\host\share\pg" };
+
+ Assert.Null(DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgres));
+ }
+
+ [Fact]
+ public void TryResolveProfileDataDirectory_LocalDataDirectory_ReturnsResolvedPath()
+ {
+ var local = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata");
+ var postgres = new PostgresConfig { Managed = true, DataDirectory = local };
+
+ var resolved = DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgres);
+
+ Assert.NotNull(resolved);
+ Assert.Equal(Path.GetFullPath(local), resolved);
+ }
+
+ [Fact]
+ public void ResolveVolumeAnchor_ManagedUncDataDirectory_FallsBackToServiceBaseDirectory()
+ {
+ var postgres = new PostgresConfig { Managed = true, DataDirectory = @"\\host\share\pg" };
+
+ Assert.Equal(AppContext.BaseDirectory, DarlingStoreHostProfile.ResolveVolumeAnchor(postgres));
+ }
+
/* --------------------------------------------- pg_settings unit normalization --------------------------------------------- */
[Theory]
diff --git a/Darling/Darling.Tests/FleetSweepWebFeedTests.cs b/Darling/Darling.Tests/FleetSweepWebFeedTests.cs
index d00d350b7..c21b649a7 100644
--- a/Darling/Darling.Tests/FleetSweepWebFeedTests.cs
+++ b/Darling/Darling.Tests/FleetSweepWebFeedTests.cs
@@ -558,6 +558,25 @@ public void TheSweepPage_ReadsTheCadence_AndWritesNothing()
Assert.DoesNotContain("update_alert_settings", src, StringComparison.Ordinal);
}
+ ///
+ /// The Store host section (#4214 part 2): the sweeps page's own "is the STORE sized right" panel, below
+ /// Watch items. Reads get_store_host through /api/read (never a raw fetch, never a second
+ /// implementation of the verdict math #4214 part 1 already owns) and highlights every stale-* verdict —
+ /// source pins, the file's own convention for a branch there is no JS runner to execute.
+ ///
+ [Fact]
+ public void TheSweepPage_HasAStoreHostSection_AndHighlightsEveryStaleVerdict()
+ {
+ var src = FrontendSource("js/pages/sweeps.js");
+
+ Assert.Contains("el(\"h3\", { class: \"section-title\", text: \"Store host\" })", src, StringComparison.Ordinal);
+ Assert.Contains("readTool(\"get_store_host\", {})", src, StringComparison.Ordinal);
+
+ /* Every stale-* verdict highlighted, not just the exact stale_after_hardware_change string — a
+ future fifth verdict named "stale_something_else" must not silently fall through unhighlighted. */
+ Assert.Contains("v.startsWith(\"stale\")", src, StringComparison.Ordinal);
+ }
+
/// The page is reachable: the shell carries the nav entry and the router routes the hash
/// to the renderer — the two wiring points a new page can silently miss.
[Fact]
@@ -567,7 +586,9 @@ public void TheSweepPage_IsWiredIntoTheShellAndTheRouter()
var app = FrontendSource("js/app.js");
Assert.Contains("import { renderSweeps } from \"./pages/sweeps.js\";", app, StringComparison.Ordinal);
- Assert.Contains("renderSweeps(main)", app, StringComparison.Ordinal);
+ /* renderSweeps(main, opts) (#4214 round-1 review), not renderSweeps(main) — opts carries the poll
+ tick's { poll: true } through to the store host card, so it replays instead of re-fetching. */
+ Assert.Contains("renderSweeps(main, opts)", app, StringComparison.Ordinal);
Assert.Contains("#/sweeps", app, StringComparison.Ordinal);
}
@@ -606,7 +627,7 @@ public void TheSweepRoutes_LogThroughTheServiceLogger_NotTheProviderlessAppFacto
"Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs");
Assert.Contains("builder.Logging.ClearProviders();", host, StringComparison.Ordinal);
Assert.Contains(
- "DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);",
+ "DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig);",
host, StringComparison.Ordinal);
}
diff --git a/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs b/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs
new file mode 100644
index 000000000..25fcec9be
--- /dev/null
+++ b/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs
@@ -0,0 +1,88 @@
+/*
+ * 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 System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using ModelContextProtocol.Server;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Round-1 review (#4214), Low 4: every DI-service-typed parameter a registered [McpServerTool] method
+/// declares must be registered somewhere in DarlingMcpHostService.cs's own source text via
+/// AddSingleton<T> or AddSingleton(new T. If a registration is ever dropped (or put behind a
+/// condition), the SDK stops resolving that parameter from DI and instead serves it as a CLIENT argument — for
+/// PostgresConfig specifically, a remote MCP caller could then set managed: true with a UNC
+/// dataDirectory and make the service open a conf file on a remote share as its own account (the NTLM-relay
+/// vector Low 4 in the round-1 review describes). Reuses
+/// 's registered-tool-class regex rather than re-deriving
+/// it, and for the same service-vs-model-supplied split the
+/// budget census already uses.
+///
+/// Coupled to item 3 by design: before item 3 trimmed the MCP host's DI seat to
+/// AddSingleton(new PostgresConfig { Managed = ..., DataDirectory = ... }), the old
+/// AddSingleton(config.Postgres) line did not name PostgresConfig anywhere in source text, so this
+/// test only starts passing once item 3 lands.
+///
+public sealed class McpServiceParameterDiSeatCensusTests
+{
+ [Fact]
+ public void EveryServiceParameterType_HasAnAddSingletonSeatInHostServiceSource()
+ {
+ var (_, types, _) = McpToolsListBudgetTests.BuildServedTools();
+ var hostServiceSource = File.ReadAllText(
+ RepoPath("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpHostService.cs"));
+
+ var serviceParameterTypeNames = types
+ .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
+ .Where(m => m.GetCustomAttribute() is not null)
+ .SelectMany(m => m.GetParameters())
+ .Select(p => p.ParameterType)
+ .Where(McpServedSchema.IsServiceParameter)
+ /* McpSchemaCompat registers McpToolGuideCatalog itself (get_tool_guide's own seat), never through
+ DarlingMcpHostService.cs's builder.Services calls — out of scope for this census. */
+ .Where(t => t != typeof(McpToolGuideCatalog))
+ .Select(t => (Nullable.GetUnderlyingType(t) ?? t).Name)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ var missing = serviceParameterTypeNames
+ .Where(name => !hostServiceSource.Contains($"AddSingleton<{name}>", StringComparison.Ordinal)
+ && !hostServiceSource.Contains($"AddSingleton(new {name}", StringComparison.Ordinal))
+ .ToList();
+
+ Assert.True(
+ missing.Count == 0,
+ "DarlingMcpHostService.cs has no AddSingleton/AddSingleton(new T seat for: " +
+ string.Join(", ", missing) +
+ ". A DI-service-typed [McpServerTool] parameter with no registration is served as a client " +
+ "argument instead of resolved from DI.");
+
+ /* A change worth knowing about even when nothing is missing: today's distinct complex service types are
+ exactly these five (NpgsqlDataSource, PostgresConfig, DarlingAnalysisService, ILogger,
+ StoreHostProfileCache) — pin the set so a sixth type appearing here is a deliberate, reviewed
+ addition rather than a silent one. StoreHostProfileCache added deliberately (#4214 round-1 review,
+ Medium 2): get_store_host's 5-minute shared cache, registered via the typed-generic
+ AddSingleton overload this test's own Contains check requires. */
+ Assert.Equal(
+ new List { "DarlingAnalysisService", "ILogger", "NpgsqlDataSource", "PostgresConfig", "StoreHostProfileCache" },
+ serviceParameterTypeNames);
+ }
+
+ private static string RepoPath(params string[] segments) => Path.Combine(new[] { RepoRoot() }.Concat(segments).ToArray());
+
+ private static string RepoRoot([CallerFilePath] string thisFile = "")
+ => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", ".."));
+}
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt
new file mode 100644
index 000000000..34211801e
--- /dev/null
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt
@@ -0,0 +1,2 @@
+# DarlingMcpStoreHostTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
+tool get_store_host 510
diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
index 6a3fdc7df..5b2c84d3d 100644
--- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs
+++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
@@ -167,6 +167,11 @@ combined total with blocking (#4267) changes on top. */
shared 32 KB budget and is not a served description either. */
/* #4198 (qs-top, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267+#4264+#4266+#4268+#4272);
combined total with qs-top (#4273) changes on top. */
+ /* #4214 part 2: +616 bytes for the new get_store_host tool (no parameters - a store-level snapshot, like
+ get_store_metrics), pinned at McpToolsListBudget/DarlingMcpStoreHostTools.txt. Merged with origin/dev's
+ own analysis-findings/collection-health/custom-view-catalog bump above (174,236); the constant below is
+ that base plus this PR's own +616, not the two deltas added by hand. */
+ /* #4214 part 2 (merge after #4273): re-measured on the merged tree, dev (#4272+#4273) plus get_store_host. */
/* #4231 (merge): re-measured after merging origin/dev (dev now includes #4267+#4264 on top of the base
#4231 branched from); combined total unchanged from the merge base since #4231 added no head bytes. */
/* #4231 (merge after #4273): re-measured after merging origin/dev (dev now includes #4272+#4273); #4231
@@ -175,7 +180,11 @@ shared 32 KB budget and is not a served description either. */
same disclosure. get_query_store_top's head drops the old Darling/Lite split for the shared sentence
(422 -> 351, banking 71 bytes); get_top_queries_by_cpu and get_top_procedures_by_cpu each gain that same
sentence (474 -> 600 and 406 -> 532, +126 bytes apiece). Net +181, matching Lite's twin change exactly. */
- private const int TotalCeilingBytes = 174_554;
+ /* #4214/#4282 (merge with #4279): re-measured on the tree combining both independent #4273-based branches -
+ this PR's own get_store_host (+616) and dev's #4279 Lite-parity head change (+181). Constant set to the
+ value McpToolsListBudgetTests itself measured on the merged tree, not the two deltas added by hand. */
+ private const int TotalCeilingBytes = 175_170;
+
private const int ConvertedHeadCap = 1_000;
diff --git a/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs b/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs
index b42d457d7..6d6190aba 100644
--- a/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs
+++ b/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs
@@ -106,7 +106,8 @@ Its only caller is DarlingCliCommands.CheckSettingsAsync (--check-settings), a u
+ "one-shot, under ServiceCommandDeadlines.CliStoreReadSeconds (10s) rather than the serial "
+ "loop's 5s bound, and never called from the collection loop or at service startup: ruling 9 "
+ "keeps every store fact that scales with the store out of the startup profile log, so "
- + "--check-settings is this regime's only caller"),
+ + "GatherStoreFactsAsync's only TWO callers are --check-settings and the get_store_host MCP "
+ + "read below, neither of them the collection loop"),
};
///
diff --git a/Darling/Darling.Tests/SharedBaselineCacheTests.cs b/Darling/Darling.Tests/SharedBaselineCacheTests.cs
index c43ff3035..fc7597e70 100644
--- a/Darling/Darling.Tests/SharedBaselineCacheTests.cs
+++ b/Darling/Darling.Tests/SharedBaselineCacheTests.cs
@@ -142,7 +142,7 @@ public void TheService_HandsOneBaselineCacheToEveryHostsAnalysis()
Assert.Contains("new DarlingAnalysisService(postgres, planFetcher, _logger, _baselineCache)", mcp, StringComparison.Ordinal);
var web = Code("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs");
- Assert.Contains("DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);", web, StringComparison.Ordinal);
+ Assert.Contains("DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig);", web, StringComparison.Ordinal);
var endpoints = Code("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs");
Assert.Contains("new DarlingAnalysisService(postgres, baselineCache: baselineCache)", endpoints, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/StoreHostProfileCacheTests.cs b/Darling/Darling.Tests/StoreHostProfileCacheTests.cs
new file mode 100644
index 000000000..d9df266c3
--- /dev/null
+++ b/Darling/Darling.Tests/StoreHostProfileCacheTests.cs
@@ -0,0 +1,166 @@
+/*
+ * 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 System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// get_store_host's 5-minute shared cache (#4214 round-1 review, Medium 2): pins the four promises the
+/// design makes — one live gather however many callers race it, nothing gathered again inside the TTL, a
+/// fresh gather once the TTL elapses, and a failed gather never cached. Exercised through
+/// directly with a counting gather delegate and the injected clock — no
+/// real source (a store connection, a disk read) is needed for any of it.
+///
+public sealed class StoreHostProfileCacheTests
+{
+ private static readonly DateTime T0 = new(2026, 9, 25, 12, 0, 0, DateTimeKind.Utc);
+
+ /// A minimal, valid — the cache never reads into its fields, only
+ /// caches/returns the reference, so the values themselves are arbitrary.
+ private static HostProfile FixtureProfile() => new()
+ {
+ Platform = "linux",
+ IsContainerized = false,
+ ProcessorCount = 4,
+ Memory = new HostMemoryProfile(8_589_934_592, null, 8_589_934_592, true, "GlobalMemoryStatusEx"),
+ DataVolume = new HostDataVolumeProfile(107_374_182_400, 53_687_091_200, "ext4", true),
+ IsManagedStore = false,
+ Store = new HostStoreFacts("17.4", "2.99.0", 1_000_000, 99.0, 0, 0, 0),
+ Settings = Array.Empty(),
+ };
+
+ /// Two callers racing a cold cache cost ONE gather. Deterministic, no Task.Delay: call 1's
+ /// gather signals started after incrementing the counter and before parking on release, so
+ /// awaiting started proves call 1 already holds the cache's gate; call 2 started against that same
+ /// held gate then has a synchronous prefix that runs only as far as its own await on the gate
+ /// (SemaphoreSlim.WaitAsync never completes synchronously when the semaphore is already held), so
+ /// it is provably parked — not merely fast — before the counter is checked.
+ [Fact]
+ public async Task TwoConcurrentCalls_GatherOnce()
+ {
+ var counter = 0;
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => T0);
+
+ async Task Gather1(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ started.SetResult();
+ await release.Task;
+ return FixtureProfile();
+ }
+
+ Task Gather2(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ return Task.FromResult(FixtureProfile());
+ }
+
+ var call1 = cache.GetOrGatherAsync(Gather1, CancellationToken.None);
+ await started.Task;
+
+ var call2 = cache.GetOrGatherAsync(Gather2, CancellationToken.None);
+
+ Assert.False(call2.IsCompleted);
+ Assert.Equal(1, counter);
+
+ release.SetResult();
+ var results = await Task.WhenAll(call1, call2);
+
+ Assert.Equal(1, counter);
+ Assert.Same(results[0].Profile, results[1].Profile);
+ Assert.Equal(results[0].GatheredAtUtc, results[1].GatheredAtUtc);
+ }
+
+ /// A call inside the 5-minute TTL is served the cached entry: no second gather, and the same
+ /// GatheredAtUtc (the first gather's clock reading) comes back both times.
+ [Fact]
+ public async Task ACallInsideFiveMinutes_GathersNothing()
+ {
+ var counter = 0;
+ var now = T0;
+ var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => now);
+
+ Task Gather(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ return Task.FromResult(FixtureProfile());
+ }
+
+ var first = await cache.GetOrGatherAsync(Gather, CancellationToken.None);
+
+ now = now.AddMinutes(4);
+ var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None);
+
+ Assert.Equal(1, counter);
+ Assert.Same(first.Profile, second.Profile);
+ Assert.Equal(first.GatheredAtUtc, second.GatheredAtUtc);
+ Assert.Equal(T0, second.GatheredAtUtc);
+ }
+
+ /// A call after the clock has advanced 5 minutes past the cached entry's GatheredAtUtc
+ /// gathers again.
+ [Fact]
+ public async Task ACallAfterFiveMinutes_GathersAgain()
+ {
+ var counter = 0;
+ var now = T0;
+ var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => now);
+
+ Task Gather(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ return Task.FromResult(FixtureProfile());
+ }
+
+ await cache.GetOrGatherAsync(Gather, CancellationToken.None);
+
+ now = now.AddMinutes(5);
+ var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None);
+
+ Assert.Equal(2, counter);
+ Assert.Equal(now, second.GatheredAtUtc);
+ }
+
+ /// A throwing gather is never cached: the exception propagates out of the FIRST call untouched
+ /// ( wraps nothing), and the gate's finally
+ /// still releases it, so a SECOND call is free to gather again rather than finding a stuck, faulted
+ /// cache.
+ [Fact]
+ public async Task AFailedGather_IsNotCached()
+ {
+ var counter = 0;
+ var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => T0);
+
+ Task ThrowingGather(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ throw new InvalidOperationException("gather failed (test)");
+ }
+
+ Task Gather(CancellationToken token)
+ {
+ Interlocked.Increment(ref counter);
+ return Task.FromResult(FixtureProfile());
+ }
+
+ await Assert.ThrowsAsync(
+ () => cache.GetOrGatherAsync(ThrowingGather, CancellationToken.None));
+
+ var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None);
+
+ Assert.Equal(2, counter);
+ Assert.NotNull(second.Profile);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs
index 8030e1eea..edcb0132f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs
@@ -97,7 +97,13 @@ internal readonly record struct HostStoreFacts(
int UncompressedChunkCount);
/// One row of the --check-settings table: a setting's live value, where it came from, what
-/// this host would derive for it right now, and the verdict those two facts collapse to.
+/// this host would derive for it right now, and the verdict those two facts collapse to.
+/// / (round-1 review, Medium 1) are the same file/line
+/// already embeds as text for the CLI/local-log surfaces; they exist
+/// separately so can redact the path for a
+/// remote MCP/web caller without touching itself. Default to null/0 —
+/// only the managed-attribution branch of
+/// ever sets them.
internal readonly record struct HostSettingProfile(
string Name,
string CurrentValueDisplay,
@@ -105,7 +111,9 @@ internal readonly record struct HostSettingProfile(
string SourceDescription,
string DerivedValueDisplay,
long DerivedValueNormalized,
- HostSettingVerdict Verdict);
+ HostSettingVerdict Verdict,
+ string? SourceFile = null,
+ int SourceLine = 0);
/// The whole host/store/settings snapshot one --check-settings run or one service-start log
/// line reports (#4214).
@@ -309,6 +317,22 @@ internal static HostDataVolumeProfile GatherDataVolume(string anchorPath)
return new HostDataVolumeProfile(0, 0, null, false);
}
+ ///
+ /// The managed data directory, refused when it resolves to a UNC path (round-1 review, Low 4): a remote
+ /// MCP/web caller has no way to set postgres.dataDirectory today, but if it is ever configured to a
+ /// UNC path, this process would otherwise reach out to that share AS ITS OWN IDENTITY purely to read a conf
+ /// file for attribution or to stat the volume — an NTLM-relay vector, not just an unwanted network hop.
+ /// Deliberately NOT a change to itself — that
+ /// helper is shared by the writer/provisioning path too, where a UNC data directory is a different, out of
+ /// scope question. Mirrors DarlingManagedPostgres.TryResolveConfPath's own UNC refusal for an include
+ /// directive.
+ ///
+ internal static string? TryResolveProfileDataDirectory(PostgresConfig postgres)
+ {
+ var resolved = DarlingManagedPostgres.ResolveDataDirectory(postgres);
+ return resolved.StartsWith(@"\\", StringComparison.Ordinal) ? null : resolved;
+ }
+
///
/// The BYO fallback anchor for the "data volume" fact (#4214): a bring-your-own store has no data
/// directory this process necessarily knows about — postgres.connectionString may point at a
@@ -317,10 +341,12 @@ internal static HostDataVolumeProfile GatherDataVolume(string anchorPath)
/// service's disk rather than the store's. A co-located BYO deployment (Docker Compose, same VM — the
/// common case) gets a real, useful answer; a genuinely remote store gets a labelled figure instead of a
/// missing one. Part 2 (get_store_host / the web panel) can revisit this once it has a place to say "this
- /// may not be the store's own disk" beyond a CLI comment.
+ /// may not be the store's own disk" beyond a CLI comment. A managed store whose data directory resolves to
+ /// a UNC path (Low 4) takes the SAME fallback as BYO — refuses
+ /// it rather than reaching out to the share.
///
internal static string ResolveVolumeAnchor(PostgresConfig postgres)
- => postgres.Managed ? DarlingManagedPostgres.ResolveDataDirectory(postgres) : AppContext.BaseDirectory;
+ => postgres.Managed ? (TryResolveProfileDataDirectory(postgres) ?? AppContext.BaseDirectory) : AppContext.BaseDirectory;
/* ======================================== Store facts (SQL) ========================================== */
@@ -549,6 +575,54 @@ internal static (string SourceDescription, HostSettingVerdict Verdict) ClassifyV
}
}
+ /// The MCP/web-safe rendering of one setting's source (round-1 review, Medium 1). The CLI/local
+ /// surfaces (, , the stale-setting
+ /// warning in DarlingWorker) print verbatim,
+ /// which for a managed block or an operator override is a full local filesystem path — fine on a shell the
+ /// operator already has, not fine handed to a remote MCP/web caller. is
+ /// the SAME managed data directory resolved for this call; pass it
+ /// once from the caller and reuse it for every row rather than re-resolving per setting.
+ internal static string FormatSourceForMcp(HostSettingProfile setting, string? dataDirectory)
+ {
+ if (setting.SourceFile is null)
+ {
+ /* not-managed, unreadable, and bring-your-own sources are already a kind word (pg_settings.source,
+ or one of the fixed unreadable/not-visible/no-block strings) — never a path, so nothing to
+ redact. */
+ return setting.SourceDescription;
+ }
+
+ var kind = setting.Verdict is HostSettingVerdict.Matches or HostSettingVerdict.StaleAfterHardwareChange
+ ? "managed block"
+ : "operator override";
+
+ return FormattableString.Invariant($"{kind} ({SanitizeSourcePathForMcp(setting.SourceFile, dataDirectory)}:{setting.SourceLine})");
+ }
+
+ /// Redacts a conf file's absolute path to a data-directory-relative path when it lives inside
+ /// , or to the bare file name when it does not (an include living outside
+ /// the data directory, or unresolved) — never a raw absolute path, and
+ /// never a leading ..\/../ from for an outside file, which
+ /// would still leak the parent directory tree.
+ private static string SanitizeSourcePathForMcp(string file, string? dataDirectory)
+ {
+ var fullFile = Path.GetFullPath(file);
+ if (dataDirectory is not null)
+ {
+ var fullDataDirectory = Path.GetFullPath(dataDirectory);
+ var withSeparator = fullDataDirectory.EndsWith(Path.DirectorySeparatorChar)
+ ? fullDataDirectory
+ : fullDataDirectory + Path.DirectorySeparatorChar;
+
+ if (fullFile.StartsWith(withSeparator, StringComparison.OrdinalIgnoreCase))
+ {
+ return Path.GetRelativePath(fullDataDirectory, fullFile);
+ }
+ }
+
+ return Path.GetFileName(fullFile);
+ }
+
/// Converts a pg_settings (setting, unit) pair to the normalized unit each row of the
/// table compares in: whole MB for a memory/WAL setting, the bare count for a connection/worker setting.
/// Null for an unrecognized unit — the caller reports the row as unreadable rather than guessing.
@@ -620,7 +694,10 @@ internal static async Task> GatherSettingProfi
}
}
- var dataDirectory = postgres.Managed ? DarlingManagedPostgres.ResolveDataDirectory(postgres) : null;
+ /* Round-1 review, Low 4: TryResolveProfileDataDirectory refuses a UNC-resolved data directory (null),
+ which falls through to the dataDirectory-is-null branch below exactly like a bring-your-own store —
+ every setting reports not-managed, and this method never reads a file off the share. */
+ var dataDirectory = postgres.Managed ? TryResolveProfileDataDirectory(postgres) : null;
var results = new List(targets.Length);
foreach (var (name, derivedValue, unit) in targets)
@@ -650,7 +727,9 @@ internal static async Task> GatherSettingProfi
var attribution = AttributeManagedSetting(dataDirectory, name);
var (sourceDescription, verdict) = ClassifyVerdict(attribution, currentValue ?? long.MinValue, derivedValue);
- results.Add(new HostSettingProfile(name, currentDisplay, currentValue ?? 0, sourceDescription, derivedDisplay, derivedValue, verdict));
+ results.Add(new HostSettingProfile(
+ name, currentDisplay, currentValue ?? 0, sourceDescription, derivedDisplay, derivedValue, verdict,
+ attribution.File, attribution.Line));
}
return results;
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index fb817963d..76f4b2771 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -247,7 +247,7 @@ on a state it has not reasoned about. */
/// and the MCP host's analysis fill — so compare_analysis' banding here reads a series the store was already asked
/// for this analysis hour from memory. Null keeps the analysis service's baselines private to it.
///
- public static void MapAll(WebApplication app, NpgsqlDataSource postgres, CollectorRuntimeState collector, ILogger logger, BaselineCache? baselineCache = null)
+ public static void MapAll(WebApplication app, NpgsqlDataSource postgres, CollectorRuntimeState collector, ILogger logger, BaselineCache? baselineCache = null, PostgresConfig? postgresConfig = null)
{
/* Liveness AND collection state (#2953). The one health surface that does not read the store, which
makes it the only one that can answer when the store IS the problem — so it reports the collector's
@@ -299,7 +299,7 @@ its own empty state and the nav gate can read the count off the same response. *
});
/* One GET per read-only tool, calling the tool method directly (no SQL/projection re-implementation). */
- foreach (var (name, handler) in BuildReadDispatch(logger))
+ foreach (var (name, handler) in BuildReadDispatch(logger, postgresConfig))
{
app.MapGet("/api/read/" + name, async (HttpContext context) =>
{
@@ -2044,6 +2044,7 @@ private static CatalogRead R(string category, string description, params Catalog
["get_store_metrics"] = R(CatOverview, "The monitoring store's own size/compression/growth (self-metrics): a summary by default, object_kind to list one kind, an exact object_name for one object's daily series.", PInt("days_back", 30), PText("object_kind"), PText("object_name"), PLimit(DarlingMcpStoreMetricsTools.DefaultLimit)),
["get_store_log"] = R(CatOverview, "What the monitoring store's OWN PostgreSQL server log recorded - a per-class census with the capture denominator beside it, not the lines. Deliberately unbanded.", PHours(24), PLimit(DarlingMcpStoreLogTools.DefaultRetainedLimit), PAsOf()),
["get_store_query_stats"] = R(CatOverview, "The monitoring store's OWN SQL statements ranked by server-side cost (pg_stat_statements), split by the role that ran them (on a managed store: the web viewer, MCP tools, the Darling Viewer, or the service itself).", PText("role"), PText("order_by"), PTop(DarlingMcpStoreQueryStatsTools.DefaultTop), PBool("full_text", false)),
+ ["get_store_host"] = R(CatOverview, "The monitoring store's own HOST profile: platform/RAM/data volume, PostgreSQL/TimescaleDB facts, and a verdict per sizing-relevant setting against what this host would derive today - is the store sized right. No parameters; a snapshot."),
["get_collector_cost"] = R(CatOverview, "The monitoring tool's OWN per-collector cost on the monitored servers (self-monitoring) - which of our collectors is the most expensive to run. Pass collector_name for that one collector's daily trend instead of the ranked list.", PInt("days_back", 7), PText("collector_name")),
["get_collector_stall_probes"] = R(CatOverview, "The out-of-band server-wide wait samples taken while one of OUR collectors was stalled mid-read - what the monitored instance was doing inside the window the sequential sweep records nothing in. Carries the outcome census beside the samples, deliberately unbanded.", PServer(), PInt("days_back", 7), PLimit(DarlingMcpStallProbeTools.DefaultLimit)),
["get_oversized_plan_backlog"] = R(CatOverview, "The cached plans this tool measured as too large to capture inline, and what the out-of-band sweep has done about each one: per server the three verdict buckets (pending/captured/expired, a strict partition), the attempt figures on still-pending rows, the newest capture and expiry instants, and observed_bytes min/median/max, with the per-collector census beside them. Takes no window - a worklist updated in place, not a series. Pass server_name with include_rows for the claim keys.", PServer(), PBool("include_rows", false), PLimit(DarlingMcpOversizedPlanBacklogTools.DefaultLimit)),
@@ -2729,8 +2730,15 @@ private static IResult UnsupportedMediaTypeResult() =>
/// dispatch WITH the host service's logger; callers with no host behind them (the parity tests, the triage
/// section runner — neither maps that entry) build it bare, and there the null is the honest value: no
/// service log is wired to receive anything.
+ ///
+ /// rides the same way, into the one entry whose tool takes a
+ /// config seat (get_store_host, #4214 part 2 —
+ /// needs PostgresConfig to resolve the managed data directory, which no other read on this surface
+ /// touches). builds the dispatch with the web host's own config; a caller with none
+ /// gets the tool's own "unavailable" envelope rather than a null-reference throw if that one entry is ever
+ /// invoked without it.
///
- internal static IReadOnlyDictionary BuildReadDispatch(ILogger? logger = null)
+ internal static IReadOnlyDictionary BuildReadDispatch(ILogger? logger = null, PostgresConfig? postgresConfig = null)
{
return new Dictionary(StringComparer.Ordinal)
{
@@ -2939,6 +2947,11 @@ sizing the points itself (the OptionalDouble rule). */
["get_store_metrics"] = (c, pg, an) => DarlingMcpStoreMetricsTools.GetStoreMetrics(pg, QueryInt(c, "days_back", null, 30), Str(c, "object_kind"), Str(c, "object_name"), Rows(c, "limit", DarlingMcpStoreMetricsTools.DefaultLimit)),
["get_store_log"] = (c, pg, an) => DarlingMcpStoreLogTools.GetStoreLog(pg, Hours(c, 24), Rows(c, "limit", DarlingMcpStoreLogTools.DefaultRetainedLimit), AsOf(c)),
["get_store_query_stats"] = (c, pg, an) => DarlingMcpStoreQueryStatsTools.GetStoreQueryStats(pg, Str(c, "role"), Str(c, "order_by") ?? "total_time", Rows(c, "top", DarlingMcpStoreQueryStatsTools.DefaultTop), QueryBool(c, "full_text", false)),
+ /* #4214 part 2: postgresConfig rides by closure (this method's own doc comment), the same way
+ logger does for get_sweep_reports two screens up. StoreHostProfileCache.Shared as a direct
+ static reference, not a new BuildReadDispatch parameter (round-1 review, Medium 2) — unlike
+ postgresConfig, the cache instance never varies by caller, so no threading is needed. */
+ ["get_store_host"] = (c, pg, an) => DarlingMcpStoreHostTools.GetStoreHost(pg, postgresConfig, StoreHostProfileCache.Shared),
["get_collector_cost"] = (c, pg, an) => DarlingMcpCollectorCostTools.GetCollectorCost(pg, QueryInt(c, "days_back", null, 7), Str(c, "collector_name")),
["get_collector_stall_probes"] = (c, pg, an) => DarlingMcpStallProbeTools.GetCollectorStallProbes(pg, Server(c), QueryInt(c, "days_back", null, 7), Rows(c, "limit", DarlingMcpStallProbeTools.DefaultLimit)),
["get_oversized_plan_backlog"] = (c, pg, an) => DarlingMcpOversizedPlanBacklogTools.GetOversizedPlanBacklog(pg, Server(c), QueryBool(c, "include_rows", false), Rows(c, "limit", DarlingMcpOversizedPlanBacklogTools.DefaultLimit)),
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
index f8d0841bd..727bec9c3 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
@@ -492,6 +492,20 @@ listen value is itself loopback or a wildcard (0.0.0.0/::), which would collide
/* Register services that MCP tools need via dependency injection. */
builder.Services.AddSingleton(postgres);
+ /* #4214 part 2: get_store_host's config seat — the same config this host loaded to reach this
+ point, so it cannot disagree with what actually connected. Read-only: GatherAsync only ever
+ reads dataDirectory/Managed off it, never writes.
+ Trimmed copy, not config.Postgres itself (round-1 review, Low 3): the full PostgresConfig
+ also carries the owner connection string. Nothing serializes this DI registration today,
+ but injecting only the two fields GatherAsync reads means a future [McpServerTool] that
+ takes a PostgresConfig parameter cannot receive the owner secret through this seat. */
+ builder.Services.AddSingleton(new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory });
+ /* #4214 round-1 review, Medium 2: get_store_host's 5-minute shared cache — the process-wide
+ Shared instance, not a fresh one per request, so every caller (MCP and the direct-call web
+ path below) actually shares the one cache window. Typed-generic AddSingleton, not the
+ untyped AddSingleton(instance) overload: McpServiceParameterDiSeatCensusTests greps this
+ file's source text for AddSingleton specifically. */
+ builder.Services.AddSingleton(StoreHostProfileCache.Shared);
builder.Services.AddSingleton(new DarlingAnalysisService(postgres, planFetcher, _logger, _baselineCache));
/* The HOST's logger, registered as the bare ILogger a tool method can take as a DI parameter
(the postgres pattern one line up — service-typed params are resolved per request and never
@@ -716,6 +730,10 @@ a window had a quiet half and a bad half. A STORED read over the same query_stat
/* #3021 get_store_log — the store's OWN server-log census, the second self-monitoring
surface beside get_store_metrics. */
.WithGeminiCompatibleTools()
+ /* #4214 part 2 get_store_host — the store HOST's profile (platform/RAM/data volume, store
+ facts, per-setting verdicts), the read side of part 1's --check-settings verb. Darling-only:
+ Lite has no managed PostgreSQL store to profile. */
+ .WithGeminiCompatibleTools()
.WithGeminiCompatibleTools()
.WithGeminiCompatibleTools()
/* #2880 get_collector_stall_probes - the out-of-band server-wide wait samples taken
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs
index 4395cc3fc..665807238 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs
@@ -63,7 +63,7 @@ The writes this server performs (analysis mutes, Custom Views authoring, alert t
## Tools
- This server exposes 159 tools. 88 are the same names Performance Monitor Lite exposes. The remaining 71 are unique to Darling: thirty-five are the PostgreSQL reads (`get_pg_*`), plus Custom Views, custom-alert-rule, alert-tuning and server-onboarding tools, and central-store-only reads (`get_fleet_overview`, `get_ag_health` — Availability Groups — `get_sweep_reports`, `get_store_metrics`, `get_store_log`). `get_blocking` = Lite's `get_blocked_process_reports`.
+ This server exposes 160 tools. 88 are the same names Performance Monitor Lite exposes. The remaining 72 are unique to Darling: thirty-five are the PostgreSQL reads (`get_pg_*`), plus Custom Views, custom-alert-rule, alert-tuning and server-onboarding tools, and central-store-only reads (`get_fleet_overview`, `get_ag_health` — Availability Groups — `get_sweep_reports`, `get_store_metrics`, `get_store_log`). `get_blocking` = Lite's `get_blocked_process_reports`.
### Reading an empty result
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs
new file mode 100644
index 000000000..bdc5e4935
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs
@@ -0,0 +1,170 @@
+/*
+ * 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 System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Npgsql;
+using PerformanceMonitor.Common;
+
+namespace PerformanceMonitor.Darling.Service.Mcp;
+
+///
+/// get_store_host (#4214 part 2): the store host profile over MCP/web — the same model
+/// --check-settings prints (part 1, #4271), reached without a shell on the store's own host. Reuses
+/// rather than a second copy (ruling 1): host facts (OS,
+/// CPUs, RAM with its cgroup/authoritative reading, the data volume), the store facts (PostgreSQL/TimescaleDB
+/// versions, size, lifetime buffer hit, lifetime temp bytes, the #4211 uncompressed-chunks-vs-RAM figure), and
+/// a verdict per sizing-relevant setting: matches, stale-after-hardware-change,
+/// operator-override or not-managed.
+///
+/// Deliberately NOT a block on get_store_metrics (ruling 1): that tool is sized to
+/// already, and every one of its calls would pay for a read that
+/// most questions about the store never need. Lite has no managed PostgreSQL store, so this tool has no Lite
+/// twin — see Lite.Tests/CrossAppMcpToolInventoryPinTests.KnownLiteMissingMcpTools.
+///
+/// The mcp role reads this exactly as least-privilege as every other MCP read (ruling 2) —
+/// nothing here widens it. The settings' FILE attribution is read off local disk by the service process
+/// itself (), the same as part 1, needing no
+/// PostgreSQL privilege at all; only the LIVE pg_settings values and the store facts travel over the
+/// mcp role's connection.
+///
+[McpServerToolType]
+public sealed class DarlingMcpStoreHostTools
+{
+ [McpServerTool(Name = "get_store_host"), Description(
+ "The monitoring STORE's own host/settings profile - is IT sized right, not a monitored server. No "
+ + "server_name, no window: a snapshot. Reads the managed data directory's conf files off local disk for "
+ + "settings attribution (needs no elevated PostgreSQL privilege); a bring-your-own store reports every "
+ + "setting not-managed. stale_after_hardware_change means a managed block still sets a value this host's "
+ + "CURRENT RAM/disk would size differently today - the #4207/#4211 class of drift. <> Reports the "
+ + "host PostgreSQL/TimescaleDB runs on and whether the settings in force still match it: platform "
+ + "(OS, containerized, processor_count), ram (total_bytes, cgroup_limit_bytes if any, effective_bytes, "
+ + "authoritative, source), data_volume (total_bytes, free_bytes, filesystem, ready; on a bring-your-own "
+ + "store this is the SERVICE's own disk, not necessarily the store's - data_volume.note says so when "
+ + "managed is false), managed, and store (postgres_version, timescale_version, size_bytes, "
+ + "buffer_hit_ratio_percent, temp_bytes, uncompressed_chunk_bytes, uncompressed_chunk_count, "
+ + "uncompressed_chunk_percent_of_ram - the #4211 metric: how much of the store's raw ingest sits "
+ + "uncompressed against the RAM budget). settings is one row per sizing-relevant setting (shared_buffers, "
+ + "work_mem, effective_cache_size, maintenance_work_mem, the TimescaleDB worker counts, max_connections, "
+ + "max_wal_size): current (the live value), source (which conf block or ALTER SYSTEM set it), derived "
+ + "(what the sizing function would compute for THIS host right now) and verdict, one of four: matches "
+ + "(a managed block set it and it still agrees with today's derivation), stale_after_hardware_change (a "
+ + "managed block set it but re-deriving from the CURRENT host gives a different number - a resize the "
+ + "settings never caught up with), operator_override (postgresql.auto.conf, or a hand-edited line after "
+ + "every managed block, is what is actually in force), or not_managed (a bring-your-own store; nothing "
+ + "here ever wrote a block to compare against, so pg_settings.source is reported as-is). "
+ + "any_stale is true when one or more settings verdict is stale_after_hardware_change, so a caller can "
+ + "act on the summary without walking the whole table. gathered_at (UTC) is when this snapshot was "
+ + "taken; the store/settings facts are cached for up to 5 minutes and shared across callers, so a burst "
+ + "of calls costs one live read. This tool never writes a setting or a conf file.")]
+ public static async Task GetStoreHost(NpgsqlDataSource postgres, PostgresConfig? postgresConfig, StoreHostProfileCache cache)
+ {
+ if (postgresConfig is null)
+ {
+ /* Only reachable off the direct-call web path when a caller builds the dispatch table without a
+ config in scope (BuildReadDispatch's postgresConfig defaults to null for such callers, mirroring
+ its logger parameter) — true MCP dispatch always resolves this from DI, registered once at host
+ start from the same DarlingConfig every other seat on this host shares. */
+ return McpHelpers.Status(
+ "unavailable", "The store host profile is not available on this call path (no postgres configuration in scope).");
+ }
+
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(ServiceCommandDeadlines.McpStoreHostProfileSeconds));
+
+ /* Round-1 review, Medium 2: the store/settings facts are cached for up to 5 minutes and shared
+ across callers, so a burst of calls costs one live read. The connection opens INSIDE the
+ gather delegate — a cache hit never calls this delegate at all, so a hit opens no connection. */
+ var (profile, gatheredAtUtc) = await cache.GetOrGatherAsync(
+ async token =>
+ {
+ await using var connection = await postgres.OpenConnectionAsync(token);
+ return await DarlingStoreHostProfile.GatherAsync(postgresConfig, connection, token);
+ },
+ cts.Token);
+
+ var anyStale = profile.Settings.Any(s => s.Verdict == HostSettingVerdict.StaleAfterHardwareChange);
+ var ramPercent = profile.Memory.EffectiveBytes > 0
+ ? Math.Round(100.0 * profile.Store.UncompressedChunkBytes / profile.Memory.EffectiveBytes, 1)
+ : (double?)null;
+
+ /* Round-1 review, Medium 1: resolved once, reused for every settings row below rather than
+ re-resolved per row — same managed data directory GatherSettingProfilesAsync itself resolved
+ for this call, so FormatSourceForMcp's containment test agrees with what actually attributed
+ each row. Round-1 review, Medium 1 follow-up: calls TryResolveProfileDataDirectory (not the
+ raw ResolveDataDirectory) so a UNC-configured managed store agrees with
+ GatherSettingProfilesAsync's own refusal (Low 4) instead of silently resolving a path
+ GatherSettingProfilesAsync never used. */
+ var mcpDataDirectory = postgresConfig.Managed ? DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgresConfig) : null;
+
+ return JsonSerializer.Serialize(new
+ {
+ platform = profile.Platform,
+ containerized = profile.IsContainerized,
+ processor_count = profile.ProcessorCount,
+ ram = new
+ {
+ total_bytes = profile.Memory.TotalBytes,
+ cgroup_limit_bytes = profile.Memory.CgroupLimitBytes,
+ effective_bytes = profile.Memory.EffectiveBytes,
+ authoritative = profile.Memory.IsAuthoritative,
+ source = profile.Memory.SourceDescription,
+ },
+ data_volume = new
+ {
+ total_bytes = profile.DataVolume.TotalBytes,
+ free_bytes = profile.DataVolume.FreeBytes,
+ filesystem = profile.DataVolume.Filesystem,
+ ready = profile.DataVolume.IsReady,
+ /* #4214 part 1's own doc comment on ResolveVolumeAnchor flagged this as part 2's to say:
+ a bring-your-own store's "data volume" is this SERVICE's own disk, which may not be the
+ store's disk at all (postgres.connectionString can point anywhere). */
+ note = profile.IsManagedStore
+ ? null
+ : "This is the service's own disk, not necessarily the store's — a bring-your-own postgres.connectionString may point at a different host entirely.",
+ },
+ managed = profile.IsManagedStore,
+ store = new
+ {
+ postgres_version = profile.Store.PostgresVersion,
+ timescale_version = profile.Store.TimescaleVersion,
+ size_bytes = profile.Store.StoreSizeBytes,
+ buffer_hit_ratio_percent = profile.Store.BufferHitRatioPercent is { } hit ? Math.Round(hit, 1) : (double?)null,
+ temp_bytes = profile.Store.TempBytes,
+ uncompressed_chunk_bytes = profile.Store.UncompressedChunkBytes,
+ uncompressed_chunk_count = profile.Store.UncompressedChunkCount,
+ uncompressed_chunk_percent_of_ram = ramPercent,
+ },
+ settings = profile.Settings.Select(s => new
+ {
+ name = s.Name,
+ current = s.CurrentValueDisplay,
+ derived = s.DerivedValueDisplay,
+ source = DarlingStoreHostProfile.FormatSourceForMcp(s, mcpDataDirectory),
+ verdict = DarlingStoreHostProfile.DescribeVerdict(s.Verdict).Replace('-', '_'),
+ }),
+ any_stale = anyStale,
+ /* Round-1 review, correcting the original plan: naive-UTC (no trailing Z), matching every
+ other "_at" timestamp on an MCP payload in this codebase (DarlingFleetReader.GeneratedAt,
+ DarlingAgReader, DarlingMcpConfigHistoryTools's NaiveUtc helper, DarlingMcpTrendTools) —
+ not a DateTimeKind.Utc value, which would serialize with a trailing Z instead. */
+ gathered_at = DateTime.SpecifyKind(gatheredAtUtc, DateTimeKind.Unspecified),
+ }, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_store_host", ex);
+ }
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
index 3b46c0b8f..685c5e78e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
@@ -792,7 +792,10 @@ this is a method the live-HTTP test calls too rather than a block copied into it
/* #4220: web.publicBaseUrl's host, admitted as one extra allowed Host header value — see
ConfigurePipeline's publicBaseUrlHost param and TriageLink.TryGetHost. */
var publicBaseUrlHost = TriageLink.TryGetHost(web.PublicBaseUrl);
- ConfigurePipeline(_app, postgres, networkMode, networkListenIp, allowedCidr, accessToken, oidcClient, publicBaseUrlHost);
+ /* #4214 part 2 / round-1 review Low 3: trimmed copy, not config.Postgres itself — see the
+ matching comment at DarlingMcpHostService.cs's AddSingleton(PostgresConfig) registration. */
+ var storeHostPostgresConfig = new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory };
+ ConfigurePipeline(_app, postgres, networkMode, networkListenIp, allowedCidr, accessToken, oidcClient, publicBaseUrlHost, storeHostPostgresConfig);
/* #2389: name the authority for each half of what is being started — enabled/port from whichever
plane the supervisor resolved, listen/allowFrom/token always from darling.json. */
@@ -996,7 +999,8 @@ internal void ConfigurePipeline(
IPNetwork allowedCidr,
string accessToken,
DarlingWebOidcClient? oidcClient,
- string? publicBaseUrlHost = null)
+ string? publicBaseUrlHost = null,
+ PostgresConfig? postgresConfig = null)
{
/* #2479 item 5: the gates below used to refuse silently. Rate-limited per (gate, source),
because this port is LAN-exposed on purpose - see DarlingHttpRefusalLog. Created per
@@ -1275,7 +1279,7 @@ it covers every route MapAll adds (including ones added on a parallel branch) wi
await next(context);
});
- DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);
+ DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig);
app.UseDefaultFiles();
/* Static assets carry an ETag/Last-Modified already (the framework default); no-cache (#4188) makes
diff --git a/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs b/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs
index 6b0fd6a22..0ad570ba1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs
@@ -450,6 +450,34 @@ public static class ServiceCommandDeadlines
///
public const int StartupHostProfileSeconds = CliStoreReadSeconds + 5;
+ ///
+ /// The get_store_host MCP read's own regime (#4214 part 2): a linked CTS wrapped around
+ /// DarlingStoreHostProfile.GatherAsync — the SAME orchestration --check-settings calls,
+ /// store facts included, unlike 's startup profile which never
+ /// reaches GatherStoreFactsAsync at all (ruling 9).
+ ///
+ /// Why the MCP surface needs its own outer bound where the CLI verb needs none.
+ /// CliStoreReadSeconds IS the bound for --check-settings because nothing waits behind an
+ /// operator's own console command. An MCP tool call is different: McpCommandDeadlines's own
+ /// header states that none of the [McpServerTool] methods take a CancellationToken, so a
+ /// caller that gives up leaves the read running with nothing to cancel it — exactly the gap that sweep
+ /// closed for the other 124 shipped reads. This read cannot simply take their constant
+ /// (McpCommandDeadlines.ReadSeconds, 20s): unlike every read that constant covers, this one's
+ /// component queries scale with the STORE (#3199) rather than the row, so this needed a regime of its
+ /// own rather than joining one sized for the opposite kind of read.
+ ///
+ /// ABOVE the sum of what it wraps, not tied to any one query in it.
+ /// GatherAsync runs, sequentially, the version/buffer/size/uncompressed-chunk reads
+ /// (GatherStoreFactsAsync, each individually bounded at ) and
+ /// then the pg_settings read (GatherSettingProfilesAsync, same per-statement bound) plus
+ /// eight local conf-file reads (sub-millisecond). Only two of those five statements are store-scaling
+ /// (StoreSizeSql, UncompressedChunkSizeSql — SerialLoopStoreSizeSourceTests'
+ /// regex), so the backstop clears TWO store-scaling statements at their own ceiling plus the same 5s
+ /// margin carries for everything outside the one query it
+ /// backstops.
+ ///
+ public const int McpStoreHostProfileSeconds = (CliStoreReadSeconds * 2) + 5;
+
///
/// The store<->service COMMAND plane's own bookkeeping — the stale-command reaper, the atomic
/// claim, the desired-state store write a claimed command dispatches, the terminal result report,
diff --git a/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs b/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs
new file mode 100644
index 000000000..1418ac2b9
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs
@@ -0,0 +1,108 @@
+/*
+ * 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 System.Threading;
+using System.Threading.Tasks;
+
+namespace PerformanceMonitor.Darling.Service;
+
+///
+/// The 5-minute shared cache behind get_store_host (#4214 round-1 review, Medium 2): the store/settings
+/// facts reads are a live pg_settings query plus a
+/// pg_database_size read whose cost scales with the store (measured 3,177 ms on a 225 GiB store — see
+/// SerialLoopStoreSizeSourceTests, #3199), so a burst of MCP calls should cost one live read, not one
+/// per call.
+///
+/// One immutable behind a single volatile reference field, not a bare
+/// tuple field read without a lock: a tuple field is not read/written atomically and can tear under
+/// concurrent access, where a single reference assignment is atomic and volatile gives the needed
+/// cross-thread visibility for the lock-free fast path (a fresh hit never touches the gate).
+///
+/// A failed gather is never cached: only assigns
+/// after in 's own call returns successfully, and its
+/// finally always releases the gate, so a thrown exception leaves the next call free to try again
+/// rather than stuck behind a faulted cache.
+///
+public sealed class StoreHostProfileCache : IDisposable
+{
+ /// The production singleton, registered once per host start (DarlingMcpHostService.cs,
+ /// DarlingWebEndpoints.cs's direct-call dispatch) so every caller shares the same 5-minute window.
+ /// A test builds its own instance instead, with an injected clock.
+ public static readonly StoreHostProfileCache Shared = new(TimeSpan.FromMinutes(5));
+
+ private sealed record CacheEntry(HostProfile Profile, DateTime GatheredAtUtc);
+
+ private readonly TimeSpan _ttl;
+ private readonly Func _utcNow;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private volatile CacheEntry? _entry;
+
+ /// Public, not internal (unlike the rest of this file's neighbors, which favor internal):
+ /// GetStoreHost is a public [McpServerTool] method — the SDK's own convention, matched by
+ /// every other DI-service-typed parameter it takes (PostgresConfig, DarlingAnalysisService)
+ /// — and a parameter type may never be less accessible than the method it appears on (CS0051).
+ public StoreHostProfileCache(TimeSpan ttl, Func? utcNow = null)
+ {
+ _ttl = ttl;
+ _utcNow = utcNow ?? (() => DateTime.UtcNow);
+ }
+
+ /// Returns the cached profile when it is still fresh; otherwise calls
+ /// exactly once, even under concurrent callers, and caches the result. The connection needs is the caller's to open, INSIDE the delegate — a cache hit never calls
+ /// at all, so a hit opens no connection.
+ ///
+ /// Internal, not public (unlike the class itself and its constructor): is
+ /// internal, so this method's signature can be no more accessible than that without CS0051. Every real
+ /// caller (GetStoreHost) and every test lives in this same assembly or
+ /// Darling.Tests (InternalsVisibleTo), so internal is not a reach restriction in
+ /// practice.
+ internal async Task<(HostProfile Profile, DateTime GatheredAtUtc)> GetOrGatherAsync(
+ Func> gather, CancellationToken cancellationToken)
+ {
+ if (TryGetFresh() is { } fresh)
+ {
+ return (fresh.Profile, fresh.GatheredAtUtc);
+ }
+
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ /* Re-check after winning the gate: a caller that lost the race to whoever gathered while this
+ call waited should reuse that result rather than gathering a second time. */
+ if (TryGetFresh() is { } freshAfterWait)
+ {
+ return (freshAfterWait.Profile, freshAfterWait.GatheredAtUtc);
+ }
+
+ var profile = await gather(cancellationToken);
+ var entry = new CacheEntry(profile, _utcNow());
+ _entry = entry;
+ return (entry.Profile, entry.GatheredAtUtc);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private CacheEntry? TryGetFresh()
+ {
+ var entry = _entry;
+ return entry is not null && _utcNow() - entry.GatheredAtUtc < _ttl ? entry : null;
+ }
+
+ /// CA1001 (owns the disposable ) — mirrors DarlingWebOidcClient's own
+ /// gate disposal. Safe for despite two hosts (DarlingMcpHostService,
+ /// DarlingWebEndpoints.cs's direct-call dispatch) both holding a reference to it: both register it
+ /// with the DI INSTANCE overload (AddSingleton<StoreHostProfileCache>(Shared)), and the
+ /// built-in container never disposes an instance it did not itself construct — so no container shutdown
+ /// disposes out from under the other host.
+ public void Dispose() => _gate.Dispose();
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
index 96da32cae..d33a81014 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
@@ -112,8 +112,10 @@ function serverRoute(rest) {
/**
* @param {object} [opts] — forwarded to renderServer; `{ poll: true }` marks this call as the 60s poll's own
- * refresh rather than a hashchange (sub-tab click, deep link) or the first paint (#4190/#4191). Not meaningful
- * to any other page today, so every other renderX() call below ignores it.
+ * refresh rather than a hashchange (sub-tab click, deep link) or the first paint (#4190/#4191). Also forwarded
+ * to renderSweeps (#4214 round-1 review), which threads it into the store host card so a poll tick replays
+ * that card's last-fetched payload instead of re-fetching (the profile it reports changes rarely — a hardware
+ * or version change, never per-tick). Every other renderX() call below still ignores it.
*/
function route(opts) {
/* The session-expired takeover owns the DOM from the moment it fires until the operator signs in again —
@@ -126,7 +128,7 @@ function route(opts) {
setActiveNav(r);
if (r.name === "server") renderServer(main, r.param, r.tab, opts);
else if (r.name === "ag") renderAg(main);
- else if (r.name === "sweeps") renderSweeps(main);
+ else if (r.name === "sweeps") renderSweeps(main, opts);
else if (r.name === "alerts") renderAlerts(main);
else if (r.name === "alertRules") renderAlertRuleList(main);
else if (r.name === "alertEditor") renderAlertEditor(main, r.id, r.template);
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
index 4aaeb5beb..9bbc4e94d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
@@ -24,7 +24,7 @@
*/
import { el, mount, apiGet, readTool, loadingStrip, errorStrip, emptyStrip, localTime, relTime, rollupTextId,
- fmtInt, bandClass, disclosure } from "../util.js";
+ fmtInt, fmtMb, fmtPct, fmtBool, fmtText, bandClass, disclosure } from "../util.js";
/* The watch-item state vocabulary — the label each FleetSweepWatchStateMachine state owes an operator. The
KEYS are the machine's own constants; Darling.Tests.FleetSweepWebFeedTests parses THIS OBJECT and compares
@@ -54,10 +54,15 @@ let sweepSpanHours = 1;
let selectedSweepId = null;
let watchStateShown = null; // null = the open + carried default view; "closed" etc. on request
-export async function renderSweeps(main) {
+/* The store host card's last successfully fetched payload (#4214 round-1 review): a poll tick replays this
+ instead of re-fetching — see renderStoreHost below for why. null until the first successful fetch. */
+let lastStoreHostPayload = null;
+
+export async function renderSweeps(main, opts) {
const detailBox = el("div", {});
const timelineBox = el("div", {});
const watchBox = el("div", {});
+ const storeHostBox = el("div", {});
const cadenceMeta = el("div", { class: "meta", text: "" });
mount(main, [
@@ -72,6 +77,8 @@ export async function renderSweeps(main) {
timelineBox,
el("h3", { class: "section-title", text: "Watch items" }),
watchBox,
+ el("h3", { class: "section-title", text: "Store host" }),
+ storeHostBox,
]);
/* Independent sections load in parallel; each degrades alone (the triage page's rule). */
@@ -79,6 +86,7 @@ export async function renderSweeps(main) {
renderDetail(detailBox);
renderTimeline(timelineBox, detailBox);
renderWatchItems(watchBox);
+ renderStoreHost(storeHostBox, opts);
}
/* ─────────────────────────── the cadence display (read-only) ─────────────────────────── */
@@ -412,6 +420,102 @@ function watchStateSev(state) {
return "sev-Healthy";
}
+/* ─────────────────────────── the store host profile (#4214 part 2) ─────────────────────────── */
+
+/* get_store_host: is the monitoring STORE itself sized right, not a monitored server — a read-only snapshot
+ (platform/RAM/data volume, PostgreSQL/TimescaleDB facts, one row per sizing-relevant setting). Read-only on
+ this page like every other section here: the CLI --check-settings verb and the companion sizing issue own
+ any write. Darling-only (Lite has no managed PostgreSQL store), so this section has no Lite parity to keep.
+
+ #4214 round-1 review: a poll tick (opts.poll — see app.js's route() doc comment) with an already-fetched
+ payload replays it instead of re-fetching. The tool's own server-side cache already makes a burst of calls
+ cheap (5 minutes, shared across callers), but this page's 60s poll would still cross the network and pay
+ the JSON round-trip every tick for a profile that changes only on a hardware or version change — never
+ per-tick. A hashchange/first-paint/span-change call (opts.poll not true) always fetches fresh. */
+async function renderStoreHost(box, opts) {
+ const isPoll = !!(opts && opts.poll === true);
+ if (isPoll && lastStoreHostPayload) {
+ renderStoreHostPayload(box, lastStoreHostPayload);
+ return;
+ }
+
+ mount(box, loadingStrip("Loading store host…"));
+ const res = await readTool("get_store_host", {});
+ if (res.kind === "error") return mount(box, errorStrip(res.message));
+ if (res.kind !== "data") return mount(box, emptyStrip(res.message || "Store host profile not available."));
+
+ lastStoreHostPayload = res.data || {};
+ renderStoreHostPayload(box, lastStoreHostPayload);
+}
+
+/** The store host card's actual render, split out from the fetch above (#4214 round-1 review) so a poll tick
+ can replay a previously fetched payload through the SAME render path a fresh fetch uses. */
+function renderStoreHostPayload(box, p) {
+ const ram = p.ram || {};
+ const vol = p.data_volume || {};
+ const store = p.store || {};
+ const settings = Array.isArray(p.settings) ? p.settings : [];
+
+ const facts = table(["Host", "Value"], [
+ [cellText("Platform"), cellText((p.platform || "—") + (p.containerized ? " (containerized)" : ""))],
+ [cellText("CPUs"), cellText(fmtInt(p.processor_count))],
+ [cellText("RAM"), cellText(fmtMb(bytesToMb(ram.effective_bytes)) + " effective of " + fmtMb(bytesToMb(ram.total_bytes)) + " total (" + fmtText(ram.source) + ")")],
+ [cellText("Data volume"), cellText(fmtMb(bytesToMb(vol.free_bytes)) + " free of " + fmtMb(bytesToMb(vol.total_bytes)) + " (" + fmtText(vol.filesystem) + ")" + (vol.note ? " — " + vol.note : ""))],
+ [cellText("Managed store"), cellText(fmtBool(p.managed))],
+ [cellText("PostgreSQL / TimescaleDB"), cellText(fmtText(store.postgres_version) + " / " + fmtText(store.timescale_version))],
+ [cellText("Store size"), cellText(fmtMb(bytesToMb(store.size_bytes)))],
+ [cellText("Lifetime buffer hit"), cellText(fmtPct(store.buffer_hit_ratio_percent))],
+ [cellText("Uncompressed chunks"), cellText(fmtInt(store.uncompressed_chunk_count) + " chunks, " + fmtMb(bytesToMb(store.uncompressed_chunk_bytes)) + " (" + fmtPct(store.uncompressed_chunk_percent_of_ram) + " of RAM)")],
+ ]);
+
+ const settingsBody = settings.length
+ ? table(
+ ["Setting", "Current", "Derived", "Source", "Verdict"],
+ settings.map((s) => [
+ cellText(s.name, "mono"),
+ cellText(s.current),
+ cellText(s.derived),
+ cellText(s.source),
+ el("td", { class: verdictSev(s.verdict), text: verdictLabel(s.verdict) }),
+ ]))
+ : emptyStrip("No sizing-relevant settings reported.");
+
+ mount(box, [
+ el("div", { class: "meta", text: p.any_stale
+ ? "One or more settings are stale after a hardware change."
+ : "No setting is stale after a hardware change." }),
+ facts,
+ el("h4", { class: "section-title", text: "Settings" }),
+ settingsBody,
+ ]);
+}
+
+/** get_store_host reports every size in raw bytes; the shared fmtMb helper takes MB, so every field routes
+ through here rather than re-deriving the /1024/1024 at each call site. Null-safe (fmtMb itself handles it,
+ but the division would produce NaN first without this). */
+function bytesToMb(bytes) {
+ return bytes == null ? null : bytes / (1024 * 1024);
+}
+
+/** The four get_store_host verdicts (DarlingStoreHostProfile.HostSettingVerdict), in the label an operator is
+ owed — the tool already renders the wire form with underscores (DescribeVerdict(...).Replace('-', '_')). */
+function verdictLabel(v) {
+ return v === "stale_after_hardware_change" ? "Stale after hardware change"
+ : v === "operator_override" ? "Operator override"
+ : v === "not_managed" ? "Not managed"
+ : v === "matches" ? "Matches"
+ : fmtText(v);
+}
+
+/** Every stale-* verdict highlighted (ruling 5) — the sev- vocabulary the watch-items table above already
+ uses on this page, not a new one. */
+function verdictSev(v) {
+ if (typeof v !== "string") return "";
+ if (v.startsWith("stale")) return "sev-Warning";
+ if (v === "matches") return "sev-Healthy";
+ return "";
+}
+
/* ─────────────────────────── small shared builders ─────────────────────────── */
function table(headers, rows) {
diff --git a/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs
index afd2e6122..a7531fc6c 100644
--- a/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs
+++ b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs
@@ -180,6 +180,13 @@ Postgres store measuring ITSELF (hypertable sizes/compression, payload dims, who
pg_stat_statements to read. A SKU boundary rather than a porting to-do. */
"get_store_query_stats",
+ /* #4214 part 2: the store HOST profile read (get_store_host) — platform/RAM/data volume, PostgreSQL
+ and TimescaleDB facts, and a per-setting verdict against the managed sizing this store's host was
+ derived from. Darling-ONLY by architecture, the get_store_metrics reason: Lite has no managed
+ PostgreSQL store for a host profile to be OF, and never writes the managed conf blocks the verdict
+ compares against. A SKU boundary rather than a porting to-do. */
+ "get_store_host",
+
/* #2674: the collector-cost read (get_collector_cost) over collect.collector_cost — the tool measuring
its OWN per-collector cost on the monitored servers. Darling-ONLY by architecture, the same as
get_store_metrics: it is an internal self-metric over the central store, which Lite has no twin of. */
diff --git a/README.md b/README.md
index 14df4f690..5b5c3b084 100644
--- a/README.md
+++ b/README.md
@@ -227,7 +227,7 @@ Configuration is a single JSON file with no schedule knobs. See the **[Darling o
| Alerts (tray + email + webhooks) | Yes | Email + webhooks (headless) | Yes |
| Themes | Dark and light | Dark and light | Dark and light |
| Portability | Single executable | Portable service + viewer zip (Windows), service tarball (Linux) | Server-bound |
-| MCP server (LLM integration) | Built-in (89 tools) | On request (159 tools) | Built into Dashboard (66 tools) |
+| MCP server (LLM integration) | Built-in (89 tools) | On request (160 tools) | Built into Dashboard (66 tools) |
---
@@ -365,7 +365,7 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/
### Available Tools
-**Lite** exposes 89 tools; **Darling** exposes 159 (the analysis + data-read surface plus its write tools) on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared.
+**Lite** exposes 89 tools; **Darling** exposes 160 (the analysis + data-read surface plus its write tools) on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared.
| Category | Tools |
|---|---|
diff --git a/llms.txt b/llms.txt
index eedd9fb06..cad1ae471 100644
--- a/llms.txt
+++ b/llms.txt
@@ -1,6 +1,6 @@
# SQL Server Performance Monitor
-> Free, open-source SQL Server performance monitoring tool by Erik Darling (Darling Data, LLC). 42 SQL Server collectors, real-time alerts, graphical execution plan viewer, and a built-in MCP server with 89-159 tools for AI-powered analysis. Two current editions: Lite (standalone desktop app with DuckDB storage) and Darling (headless 24/7 service with a central PostgreSQL/TimescaleDB store). The older Full/Dashboard edition is deprecated. Replaces expensive commercial tools like SolarWinds SQL Sentry (formerly SentryOne) and SolarWinds DPA. MIT licensed.
+> Free, open-source SQL Server performance monitoring tool by Erik Darling (Darling Data, LLC). 42 SQL Server collectors, real-time alerts, graphical execution plan viewer, and a built-in MCP server with 89-160 tools for AI-powered analysis. Two current editions: Lite (standalone desktop app with DuckDB storage) and Darling (headless 24/7 service with a central PostgreSQL/TimescaleDB store). The older Full/Dashboard edition is deprecated. Replaces expensive commercial tools like SolarWinds SQL Sentry (formerly SentryOne) and SolarWinds DPA. MIT licensed.
- Supports SQL Server 2016-2025, Azure SQL Managed Instance, AWS RDS for SQL Server, and Azure SQL Database (Lite and Darling)
- Darling's service runs on Windows or Linux, and its viewers are a Windows desktop app and a web dashboard