From 6c1994b653b6956011c9c7cee2f973ded02b0eac Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:07:17 -0400
Subject: [PATCH 1/3] test(darling): live HTTP proof of the MCP host gates,
including /core (#4128b)
---
.../DarlingMcpHostGateLiveTests.cs | 269 +++++
Darling/Darling.Tests/HostHeaderGuardTests.cs | 35 +-
.../Mcp/DarlingMcpHostService.cs | 916 +++++++++---------
3 files changed, 767 insertions(+), 453 deletions(-)
create mode 100644 Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
diff --git a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
new file mode 100644
index 0000000000..e1f321db6b
--- /dev/null
+++ b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
@@ -0,0 +1,269 @@
+/*
+ * 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.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4128 part (b): a live HTTP proof of the MCP host's gates, INCLUDING /core, through the SAME
+/// and
+/// methods production calls (extracted from TryStartServerAsync for exactly this purpose), driven by a
+/// real Kestrel pipeline over rather than by reading source text or calling the pure
+/// decision functions directly. already proves the WIRING ORDER by parsing
+/// the source; this class proves the wired pipeline actually REFUSES and ADMITS requests the way the source
+/// claims it does, end to end — untrusted Host headers, missing/wrong/right bearer tokens, and (the part
+/// unique to this host) that /core's tools/list is narrowed to the pinned set while a
+/// tools/call outside it gets the SDK's own unknown-tool refusal.
+///
+/// The gates read context.Connection.RemoteIpAddress, so requests go through
+/// where that is needed (the Host-header cases); the MCP conversation
+/// itself (initialize / tools/list / tools/call) is spoken as plain JSON-RPC HTTP POSTs over
+/// , since the SDK's transport reads the body, not the connection.
+///
+public sealed class DarlingMcpHostGateLiveTests
+{
+ private const string ListenIp = "192.168.1.205";
+ private const string AllowedCidr = "192.168.1.0/24";
+ private const string Token = "correct-mcp-token-value";
+ private static readonly IPAddress InCidrRemote = IPAddress.Parse("192.168.1.50");
+
+ ///
+ /// Builds a running the REAL
+ /// and — the exact methods the production
+ /// TryStartServerAsync calls, in the same order, with untrusted-field-content-safe test doubles for
+ /// the singletons the tool classes require (a data source the gates never open, since none of them touch
+ /// Postgres — a request is refused or reaches tools/list before any tool body runs).
+ ///
+ private static async Task BuildServer(bool networkMode)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+
+ // A pool the gates never open — every case here is refused, or reaches tools/list, before any tool body runs.
+ var postgres = NpgsqlDataSource.Create("Host=localhost;Database=postgres;Username=darling");
+ builder.Services.AddSingleton(postgres);
+ builder.Services.AddSingleton(new PerformanceMonitor.Darling.Analysis.DarlingAnalysisService(
+ postgres, planFetcher: null, logger: Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
+ baselineCache: new PerformanceMonitor.Darling.Analysis.BaselineCache()));
+ builder.Services.AddSingleton(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ DarlingMcpHostService.ConfigureMcpServices(builder.Services, PerformanceMonitor.Darling.Service.Mcp.DarlingPeerDirectory.Snapshot.Empty);
+
+ var app = builder.Build();
+
+ var host = new DarlingMcpHostService(
+ Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
+ new McpRuntimeState(),
+ new MonitoredServerRegistryState());
+
+ host.ConfigurePipeline(
+ app,
+ networkMode: networkMode,
+ networkListenIp: networkMode ? IPAddress.Parse(ListenIp) : null,
+ allowedCidr: IPNetwork.Parse(AllowedCidr),
+ bearerToken: Token);
+
+ await app.StartAsync();
+ return app.GetTestServer();
+ }
+
+ private static Task SendRaw(TestServer server, string path, string host, IPAddress? remote, string? bearer = null)
+ {
+ return server.SendAsync(ctx =>
+ {
+ ctx.Request.Method = "GET";
+ ctx.Request.Path = path;
+ ctx.Request.Headers.Host = host;
+ ctx.Connection.RemoteIpAddress = remote;
+ if (bearer is not null)
+ {
+ ctx.Request.Headers.Authorization = $"Bearer {bearer}";
+ }
+ });
+ }
+
+ ///
+ /// A JSON-RPC POST over stateless HTTP — no session id round-trip needed (see the Stateless = true
+ /// transport option). Built on , not CreateClient: the CIDR
+ /// gate reads context.Connection.RemoteIpAddress, which CreateClient's in-memory context
+ /// leaves null — a null remote fails the CIDR check closed by design, so every case here must set a
+ /// remote address explicitly, including the loopback-exempt ones. The Accept header carries both media
+ /// types the SDK's streamable-HTTP transport requires.
+ ///
+ private static Task<(int StatusCode, string Body)> SendJsonRpcAsync(
+ TestServer server, string path, string host, IPAddress remote, string method, string paramsJson, string? bearer)
+ {
+ var requestBody = $"{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"{method}\",\"params\":{paramsJson}}}";
+ return SendJsonRpcCoreAsync(server, path, host, remote, requestBody, bearer);
+ }
+
+ private static async Task<(int StatusCode, string Body)> SendJsonRpcCoreAsync(
+ TestServer server, string path, string host, IPAddress remote, string requestBody, string? bearer)
+ {
+ var ctx = await server.SendAsync(c =>
+ {
+ c.Request.Method = "POST";
+ c.Request.Path = path;
+ c.Request.Headers.Host = host;
+ c.Request.Headers.Accept = "application/json, text/event-stream";
+ c.Request.ContentType = "application/json";
+ var bytes = Encoding.UTF8.GetBytes(requestBody);
+ c.Request.Body = new System.IO.MemoryStream(bytes);
+ c.Request.ContentLength = bytes.Length;
+ c.Connection.RemoteIpAddress = remote;
+ if (bearer is not null)
+ {
+ c.Request.Headers.Authorization = $"Bearer {bearer}";
+ }
+
+ c.Response.Body = new System.IO.MemoryStream();
+ });
+
+ ctx.Response.Body.Seek(0, System.IO.SeekOrigin.Begin);
+ using var reader = new System.IO.StreamReader(ctx.Response.Body);
+ var body = await reader.ReadToEndAsync();
+ return (ctx.Response.StatusCode, body);
+ }
+
+ private static Task<(int StatusCode, string Body)> ToolsListAsync(TestServer server, string path, string host, IPAddress remote, string? bearer = null)
+ => SendJsonRpcAsync(server, path, host, remote, "tools/list", "{}", bearer);
+
+ private static Task<(int StatusCode, string Body)> ToolsCallAsync(TestServer server, string path, string host, IPAddress remote, string toolName, string? bearer = null)
+ => SendJsonRpcAsync(server, path, host, remote, "tools/call", $"{{\"name\":\"{toolName}\",\"arguments\":{{}}}}", bearer);
+
+ /// Network mode, no token at all: refused on BOTH / and /core before either
+ /// endpoint's handler, or the SDK transport, ever runs.
+ [Theory]
+ [InlineData("/")]
+ [InlineData("/core")]
+ public async Task NetworkMode_NoToken_IsRefusedOnBothPaths(string path)
+ {
+ using var server = await BuildServer(networkMode: true);
+
+ var (statusCode, _) = await ToolsListAsync(server, path, ListenIp, InCidrRemote);
+
+ Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
+ }
+
+ /// A foreign Host header is refused on both paths, in both modes — the DNS-rebinding guard runs
+ /// FIRST, before the bearer/CIDR checks even see the request.
+ [Theory]
+ [InlineData(true, "/")]
+ [InlineData(true, "/core")]
+ [InlineData(false, "/")]
+ [InlineData(false, "/core")]
+ public async Task ForeignHost_IsRefusedOnBothPaths_InBothModes(bool networkMode, string path)
+ {
+ using var server = await BuildServer(networkMode);
+ var ctx = await SendRaw(server, path, "evil.com", networkMode ? InCidrRemote : IPAddress.Loopback, bearer: networkMode ? Token : null);
+
+ Assert.Equal(StatusCodes.Status400BadRequest, ctx.Response.StatusCode);
+ }
+
+ /// Network mode, the right token, an in-CIDR remote: a tools/list on / succeeds — proof
+ /// the request got PAST every gate.
+ [Fact]
+ public async Task NetworkMode_RightTokenInCidr_ToolsListSucceeds()
+ {
+ using var server = await BuildServer(networkMode: true);
+
+ var (statusCode, _) = await ToolsListAsync(server, "/", ListenIp, InCidrRemote, bearer: Token);
+
+ Assert.Equal(StatusCodes.Status200OK, statusCode);
+ }
+
+ /// Loopback mode, no token: no token middleware is installed at all in this mode, so a
+ /// tools/list on / passes.
+ [Fact]
+ public async Task LoopbackMode_NoToken_ToolsListSucceeds()
+ {
+ using var server = await BuildServer(networkMode: false);
+
+ var (statusCode, _) = await ToolsListAsync(server, "/", "localhost", IPAddress.Loopback);
+
+ Assert.Equal(StatusCodes.Status200OK, statusCode);
+ }
+
+ ///
+ /// #3898 D7's own claim, proven live: a /core tools/list returns exactly
+ /// 's pinned set, and its instructions lead with the /core note —
+ /// not the full-surface instructions / serves.
+ ///
+ [Fact]
+ public async Task Core_ToolsList_IsExactlyThePinnedSet_WithTheCoreInstructionsNote()
+ {
+ using var server = await BuildServer(networkMode: false);
+
+ var (statusCode, body) = await ToolsListAsync(server, "/core", "localhost", IPAddress.Loopback);
+ Assert.Equal(StatusCodes.Status200OK, statusCode);
+
+ var payload = ReadJsonRpcResult(body);
+ var toolNames = payload.GetProperty("tools").EnumerateArray()
+ .Select(t => t.GetProperty("name").GetString() ?? "")
+ .ToHashSet();
+
+ Assert.Equal(DarlingCoreToolProfile.Closure.ToHashSet(), toolNames);
+
+ var instructions = payload.TryGetProperty("instructions", out var instructionsElement)
+ ? instructionsElement.GetString() ?? ""
+ : "";
+ Assert.Contains("/core", instructions, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// A tools/call on /core for a tool OUTSIDE its pinned set gets the SDK's own
+ /// unknown-tool error — Darling registers no fallback CallToolHandler, so this is a real subset,
+ /// not a listing filter (#3898 D7).
+ [Fact]
+ public async Task Core_ToolsCall_OutsideThePinnedSet_GetsTheSdkUnknownToolError()
+ {
+ using var server = await BuildServer(networkMode: false);
+
+ const string outsideTool = "delete_custom_view"; // a WRITE tool never in the /core read-only profile
+ Assert.DoesNotContain(outsideTool, DarlingCoreToolProfile.Closure);
+
+ var (statusCode, body) = await ToolsCallAsync(server, "/core", "localhost", IPAddress.Loopback, outsideTool);
+ Assert.Equal(StatusCodes.Status200OK, statusCode);
+
+ using var document = JsonDocument.Parse(ExtractJsonPayload(body));
+ Assert.True(document.RootElement.TryGetProperty("error", out var error), $"expected a JSON-RPC error envelope, got: {body}");
+ Assert.Contains("Unknown tool", error.GetProperty("message").GetString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static JsonElement ReadJsonRpcResult(string body)
+ {
+ using var document = JsonDocument.Parse(ExtractJsonPayload(body));
+ return document.RootElement.GetProperty("result").Clone();
+ }
+
+ /// The Streamable-HTTP transport may answer as text/event-stream (an SSE frame carrying
+ /// one data: {json} line) or as a bare JSON body, depending on negotiation; strip the SSE framing
+ /// when present so both shapes parse the same way.
+ private static string ExtractJsonPayload(string body)
+ {
+ var dataLine = body.Split('\n').FirstOrDefault(l => l.StartsWith("data:", StringComparison.Ordinal));
+ return dataLine is null ? body : dataLine["data:".Length..].Trim();
+ }
+}
diff --git a/Darling/Darling.Tests/HostHeaderGuardTests.cs b/Darling/Darling.Tests/HostHeaderGuardTests.cs
index 3726887d3a..6514e0954f 100644
--- a/Darling/Darling.Tests/HostHeaderGuardTests.cs
+++ b/Darling/Darling.Tests/HostHeaderGuardTests.cs
@@ -143,10 +143,9 @@ private static string ReadHostSource(string fileName)
var source = File.ReadAllText(path);
/* Guard the guard: if the host were renamed or restructured past recognition, the assertions below
- could pass vacuously on an empty/mismatched parse. Pin the anchors they all key off. "app.Use("
- (not "_app.Use(") matches both hosts: the MCP host still calls the field directly (_app.Use), while
- the web host's gates were extracted (#4128) into ConfigurePipeline(WebApplication app, ...), which
- calls app.Use — "app.Use(" is a substring of "_app.Use(" too, so this anchor covers both shapes. */
+ could pass vacuously on an empty/mismatched parse. Pin the anchors they all key off. Both hosts'
+ gates are extracted (#4128 parts a and b) into a ConfigurePipeline(WebApplication app, ...) method
+ that calls app.Use, not _app.Use — "app.Use(" covers both shapes. */
Assert.Contains("_app = builder.Build();", source, StringComparison.Ordinal);
Assert.Contains("app.Use(", source, StringComparison.Ordinal);
return source;
@@ -213,7 +212,7 @@ public void McpHost_RunsTheGuardBeforeMapMcp()
{
var source = ReadHostSource("DarlingMcpHostService.cs");
var guard = source.IndexOf("IsAllowedHost(context.Request.Host.Host", StringComparison.Ordinal);
- var mapMcp = source.IndexOf("_app.MapMcp();", StringComparison.Ordinal);
+ var mapMcp = source.IndexOf("app.MapMcp();", StringComparison.Ordinal);
Assert.True(mapMcp >= 0, "MapMcp is no longer called — this guard needs rewriting");
Assert.True(guard >= 0 && guard < mapMcp, "the Host-header guard must run before MapMcp registers the MCP endpoints");
@@ -226,7 +225,7 @@ public void McpHost_RunsTheGuardBeforeMapMcpCore()
{
var source = ReadHostSource("DarlingMcpHostService.cs");
var guard = source.IndexOf("IsAllowedHost(context.Request.Host.Host", StringComparison.Ordinal);
- var mapMcpCore = source.IndexOf("_app.MapMcp(\"/core\");", StringComparison.Ordinal);
+ var mapMcpCore = source.IndexOf("app.MapMcp(\"/core\");", StringComparison.Ordinal);
Assert.True(mapMcpCore >= 0, "/core is no longer mapped — this test needs rewriting");
Assert.True(guard >= 0 && guard < mapMcpCore, "the Host-header guard must run before MapMcp(\"/core\") registers the /core endpoint");
@@ -246,8 +245,8 @@ public void McpHost_RunsTheNetworkModeGateBeforeBothMapMcpCalls()
var afterBuild = source[(source.IndexOf("_app = builder.Build();", StringComparison.Ordinal) + 1)..];
var networkGate = afterBuild.IndexOf("if (networkMode)", StringComparison.Ordinal);
- var mapMcpRoot = afterBuild.IndexOf("_app.MapMcp();", StringComparison.Ordinal);
- var mapMcpCore = afterBuild.IndexOf("_app.MapMcp(\"/core\");", StringComparison.Ordinal);
+ var mapMcpRoot = afterBuild.IndexOf("app.MapMcp();", StringComparison.Ordinal);
+ var mapMcpCore = afterBuild.IndexOf("app.MapMcp(\"/core\");", StringComparison.Ordinal);
Assert.True(networkGate >= 0, "expected a network-mode-only middleware block (bearer token, CIDR) to exist after Build()");
Assert.True(mapMcpRoot >= 0, "MapMcp() is no longer called — this test needs rewriting");
@@ -268,11 +267,23 @@ public void McpHost_RunsTheNetworkModeGateBeforeBothMapMcpCalls()
public void McpHost_HasNoPathConditionalMiddlewareBeforeTheMcpMappings()
{
var source = ReadHostSource("DarlingMcpHostService.cs");
+
+ /* #4128 part (b): the pipeline wiring (guards through both MapMcp calls) is extracted into
+ ConfigurePipeline(WebApplication app, ...), a method the file now defines textually AFTER
+ TryStartServerAsync — so the file's ConfigureMcpServices method (with its own, unrelated
+ context.Request.Path check inside ConfigureSessionOptions) sits, in raw text order, between
+ TryStartServerAsync's "_app = builder.Build();" and ConfigurePipeline's MapMcp("/core") call. A
+ whole-file text slice from the first Build() to the first MapMcp("/core") would therefore catch
+ an unrelated Request.Path check that is never in the actual request pipeline. Scope the search to
+ ConfigurePipeline's OWN body instead — that is the real, in-order pipeline this claim is about. */
+ var pipelineMethodStart = source.IndexOf("internal void ConfigurePipeline(", StringComparison.Ordinal);
+ Assert.True(pipelineMethodStart >= 0, "ConfigurePipeline is gone — this test needs rewriting");
+
var build = source.IndexOf("_app = builder.Build();", StringComparison.Ordinal);
- var mapMcpCore = source.IndexOf("_app.MapMcp(\"/core\");", StringComparison.Ordinal);
- Assert.True(build >= 0 && mapMcpCore > build, "expected Build() before MapMcp(\"/core\") — this test needs rewriting");
+ var mapMcpCore = source.IndexOf("app.MapMcp(\"/core\");", pipelineMethodStart, StringComparison.Ordinal);
+ Assert.True(build >= 0 && mapMcpCore > pipelineMethodStart, "expected Build() before MapMcp(\"/core\") — this test needs rewriting");
- var pipeline = source[build..mapMcpCore];
+ var pipeline = source[pipelineMethodStart..mapMcpCore];
foreach (var branch in new[] { "Request.Path", "UseWhen(", "MapWhen(", ".Map(", "UsePathBase(" })
{
Assert.False(
@@ -291,7 +302,7 @@ public void McpHost_HasNoPathConditionalMiddlewareBeforeTheMcpMappings()
public void McpHost_KeepsTheTransportStatelessWhileCoreIsMapped()
{
var source = ReadHostSource("DarlingMcpHostService.cs");
- Assert.True(source.Contains("_app.MapMcp(\"/core\");", StringComparison.Ordinal), "/core is no longer mapped — this test needs rewriting");
+ Assert.True(source.Contains("app.MapMcp(\"/core\");", StringComparison.Ordinal), "/core is no longer mapped — this test needs rewriting");
var transport = source.IndexOf(".WithHttpTransport(options =>", StringComparison.Ordinal);
var stateless = source.IndexOf("options.Stateless = true;", StringComparison.Ordinal);
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
index e205bf4b70..2eb5431ef6 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
@@ -534,450 +534,11 @@ store column defers to. */
}
/* Register MCP server with the analysis tool class. */
- builder.Services
- .AddMcpServer(options =>
- {
- options.ServerInfo = new()
- {
- Name = "PerformanceMonitorDarling",
- Version = "1.0.0"
- };
- options.ServerInstructions = DarlingMcpInstructions.Build(declaredPeers);
- })
- /* Stateless mode: each request is self-contained (no Mcp-Session-Id round-trip).
- Required for clients like Google Antigravity that don't echo the session id,
- which otherwise connect but list zero tools (issue #1074).
-
- ConfigureSessionOptions is the /core profile's ONLY hook (#3898 D7): in Stateless mode the
- SDK invokes it on every request with that request's HttpContext, after ToolCollection is
- already populated from every tool class registered below. A /core request gets
- ToolCollection replaced with DarlingCoreToolProfile's closure; every other path (in
- practice, just /) is untouched and keeps the full set. This is a REAL subset, not a listing
- filter: the SDK's tools/call dispatch checks the same ToolCollection before falling back to
- any handler, and Darling registers no fallback CallToolHandler, so a tools/call for a tool
- outside the closure gets the SDK's own "unknown tool" error on /core. Security posture is
- inherited for free: this callback runs from inside the MCP transport, AFTER every _app.Use
- middleware below (Host-header guard, bearer token, CIDR) — a /core request is refused there
- exactly as a / request would be, before this callback, or MapMcp, ever runs.
- The subset holds only in Stateless mode, where this callback runs on every request. A stateful
- session is looked up by its id alone, not by route, so one opened on / could call any tool on
- /core. HostHeaderGuardTests pins Stateless while /core is mapped. The instructions change too:
- the shared text counts and describes every tool on /, so /core leads with a note saying what
- it serves. */
- .WithHttpTransport(options =>
- {
- options.Stateless = true;
- options.ConfigureSessionOptions = (context, sessionOptions, _) =>
- {
- if (DarlingCoreToolProfile.IsCorePath(context.Request.Path))
- {
- sessionOptions.ToolCollection = DarlingCoreToolProfile.FilterToolCollection(sessionOptions.ToolCollection);
- sessionOptions.ServerInstructions = DarlingCoreToolProfile.CoreInstructions(sessionOptions.ServerInstructions);
- }
-
- return Task.CompletedTask;
- };
- })
- /* WithGeminiCompatibleTools (not the SDK's WithTools) rewrites parameter schemas into
- the subset Gemini/Antigravity accepts — collapsing nullable type unions and
- dropping the default keyword. The companion to stateless transport for issue #1074. */
- .WithGeminiCompatibleTools()
- /* The five plan-analysis tools (analyze_query_plan / analyze_procedure_plan /
- analyze_query_store_plan / analyze_plan_xml / get_plan_xml) — the same names the
- Dashboard and Lite expose, fetching the collectors' STORED plan XML from Postgres
- (no live monitored-server hit) and running the SHARED PlanAnalysis engine. */
- .WithGeminiCompatibleTools()
- /* The core data-read tools (resource metrics, query performance, discovery/health —
- get_cpu_utilization / get_wait_stats / get_wait_trend / get_memory_stats /
- get_memory_clerks / get_file_io_stats / get_tempdb_trend / get_perfmon_stats /
- get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top /
- list_servers / get_collection_health / get_server_properties), the same names Lite
- and the Dashboard expose, over Darling's Postgres store (STORED reads, no live hit).
- These are the tools the analysis findings' next_tools recommendations point at. */
- .WithGeminiCompatibleTools()
- /* get_query_store_regressions (#2484) — the viewer's Query Store Regressions tab. Every
- other Query Store read answers what is EXPENSIVE; this answers what got WORSE, which is
- not derivable from the first (the costliest query is usually the one that always was).
- A STORED read over the same query_store_stats the tools above read. */
- .WithGeminiCompatibleTools()
- /* get_query_store_clutter (#3797) — the Query Store CLUTTER view: per-database read cost
- (the collection_log fan-out rollup), plan churn (raw query_store_stats plan identities) and
- configuration (query_store_health), plus ONE per-server overhead block (the non-sleep QDS_*
- wait deltas and MEMORYCLERK_QUERYDISKSTORE). Composed from rows the collectors already
- write — not a new query against the target. Darling-only for now: every input exists on
- Lite too, so the twin is a port, not a SKU boundary (CrossAppMcpToolInventoryPinTests). */
- .WithGeminiCompatibleTools()
- /* get_query_heatmap (#2484) — the viewer's Query Heatmap tab. The interactive plot is
- desktop-only by design; the READ behind it is not, and a bucketed table is the same
- answer. It is the only query read with a TIME axis: the rankings above cannot show that
- a window had a quiet half and a bad half. A STORED read over the same query_stats. */
- .WithGeminiCompatibleTools()
- /* The diagnostic-depth data-read tools (blocking/deadlocks, sessions, config-history,
- index/object) — get_blocking / get_deadlocks / get_deadlock_detail /
- get_blocked_process_xml, get_session_stats / get_active_queries / get_waiting_tasks,
- get_server_config_changes / get_database_config_changes / get_trace_flag_changes /
- get_database_scoped_config, get_table_index_sizes / get_index_usage / get_object_locking /
- get_database_sizes — the same names Lite and the Dashboard expose, over Darling's Postgres
- store (STORED reads, no live hit). Result shapes follow Lite where the two SKUs diverge. */
- .WithGeminiCompatibleTools()
- /* #2028 get_plan_corrections — automatic plan correction activity + per-database
- FORCE_LAST_GOOD_PLAN enablement, the one collected table that previously had no
- agent-readable path at all. Twin registered in Lite's host. */
- .WithGeminiCompatibleTools()
- /* #2029 get_pvs_stats — the ADR persistent version store, previously reachable only
- indirectly (alert knobs + compose measures). Twin registered in Lite's host. */
- .WithGeminiCompatibleTools()
- /* #2068 get_store_metrics — the monitoring store's OWN hourly self-metrics series
- (per-hypertable size/compression, payload-dimension sizes + row counts, whole-store
- size + enabled-server count) for capacity forecasting. Darling-only: a single-server
- edition has no central store to measure, so no Lite twin. */
- .WithGeminiCompatibleTools()
- /* #3021 get_store_log — the store's OWN server-log census, the second self-monitoring
- surface beside get_store_metrics. */
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- /* #2880 get_collector_stall_probes - the out-of-band server-wide wait samples taken
- while one of OUR collectors was stalled mid-read. Darling-only: the arm is installed by
- DarlingCollectorRunner's server-scoped path, which Lite's runner does not have. */
- .WithGeminiCompatibleTools()
- /* #3398 get_oversized_plan_backlog - the V121 worklist of cached plans the capture cap
- declined, and what the out-of-band sweep has done about each one. Darling-only: the
- sweep is a fleet-level errand on the headless worker's own cadence, which Lite's
- single-instance runner has no counterpart of. */
- .WithGeminiCompatibleTools()
- /* #1496 get_long_query_completions — the opt-in long-query completion trace (rpc/batch over
- the duration threshold + attentions), over Darling's Postgres store (STORED read). */
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- /* The resource-contention + jobs data-read tools — get_latch_stats / get_spinlock_stats,
- get_resource_semaphore / get_memory_grants, get_plan_cache_bloat / get_cpu_scheduler_pressure,
- get_running_jobs — the same names Lite and the Dashboard expose, over Darling's Postgres store
- (STORED reads of the collected latch/spinlock/memory-grant/plan-cache/cpu-scheduler/running-job
- snapshots, no live hit). The Dashboard-only CASE enrichment (latch severity/description/
- recommendation, spinlock description) and the #1410 client-side classifications (plan-cache
- bloat_level, cpu-scheduler pressure_level) are reproduced service-side so the full result shape
- is served. Per-second rates divide by each row's stored sample_interval_seconds (V127, #3540),
- falling back to the LAG interval only for pre-V127 rows, and are null when the latest interval
- was unknowable rather than 0. */
- .WithGeminiCompatibleTools()
- /* get_pg_wait_stats — PostgreSQL wait events for an Aurora target, paired with the
- pg_wait_stats collector. A separate tool from get_wait_stats rather than a widened
- one: PostgreSQL's waits are a two-level type/event taxonomy with no signal-wait
- concept, reported in microseconds, so the two engines cannot share a result shape
- without lying about a unit or emitting mostly-null columns. */
- .WithGeminiCompatibleTools()
- /* get_pg_cpu_utilization — instance-level CPU for a PostgreSQL/Aurora target (#2719),
- paired with the pg_cpu_utilization collector. Sourced from AWS Performance Insights
- rather than a database connection, so it sits beside the wait tools rather than the
- activity ones: a gauge over time, like SQL Server's own CPU read, not a ranked list. */
- .WithGeminiCompatibleTools()
- /* get_pg_top_queries — PostgreSQL query shapes by total time, paired with the
- pg_statement_stats collector. Carries Aurora's I/O source split and per-statement
- peak memory, neither of which the SQL Server tools have an equivalent for. */
- .WithGeminiCompatibleTools()
- /* get_pg_plans — the plan itself, not a pointer to one (#2567). Registered beside the
- statement tools because that is the join: a plan is read alongside the statement it
- belongs to, on query_id. Carries get_pg_plan_capture_readiness too (#3070): whether the
- target can capture a plan at all, facet by facet with the remedy for each, which is the
- read somebody needs the moment the plans one comes back empty. */
- .WithGeminiCompatibleTools()
- /* get_pg_logging_audit (#3607) - the rest of the logging surface, in readiness's shape:
- log_lock_waits, log_temp_files, log_autovacuum_min_duration, log_checkpoints,
- log_connections / log_disconnections and log_min_duration_statement, each judged from
- the stored pg_server_config snapshot with what it unlocks, the recommended value and its
- cost, and the remedy in the hosting flavour's syntax. Registered beside the plan tools
- because it is the other half of one onboarding question - is this target telling us
- everything it could - and lists plan capture's own settings with a pointer to the
- readiness read rather than judging them twice. */
- .WithGeminiCompatibleTools()
- /* get_pg_wraparound_risk — XID/MultiXact freeze headroom, the highest-consequence
- PostgreSQL signal and one with no SQL Server counterpart. Not Aurora-gated. */
- .WithGeminiCompatibleTools()
- /* get_pg_xmin_horizon — why vacuum reclaims nothing, attributed to one of four causes
- that are indistinguishable by symptom and need different fixes. */
- .WithGeminiCompatibleTools()
- /* get_pg_replication_slots — the other half of the abandoned-slot story. The xmin tool
- reports a slot pinning the horizon; this one reports the WAL it is retaining, which is
- unbounded by default and fills the volume regardless of what vacuum is doing. */
- .WithGeminiCompatibleTools()
- /* get_pg_autovacuum_health — which tables autovacuum is not keeping up with, ranked by
- how far past each table's OWN threshold it is. The ratio is the whole tool: a
- dead-tuple count is not comparable between a 50-million-row table and a 10,000-row
- one, and the threshold is what makes it so. */
- .WithGeminiCompatibleTools()
- /* get_pg_io_stats — I/O attributed to who/what/why rather than to a file. The context
- dimension has no SQL Server counterpart and is what separates a buffer-pool miss that
- more memory would fix from a ring-buffered sequential scan that it would not. */
- .WithGeminiCompatibleTools()
- /* get_pg_blocking — who is blocked by whom, assembled from the stored edge list into chains
- with the ROOT attributed. The one PostgreSQL read whose caveat has to travel WITH the
- answer: SQL Server's blocked-process report is engine-recorded, this is periodically
- sampled, so "no blocking" here means "none was sampled" and the tool reports its own
- capture count so that distinction cannot be lost. */
- .WithGeminiCompatibleTools()
- /* get_pg_database_stats — four questions off one cluster-wide view: temp-file spills (the
- PostgreSQL answer to "why is this query slow" that no other read here can give on a stock
- target), the buffer-cache hit ratio, a server-recorded deadlock count, and the
- commit/rollback split. The one read whose reset handling is part of its contract: a
- statistics reset is reported as a reset rather than surfacing as a negative rate. */
- .WithGeminiCompatibleTools()
- /* get_pg_index_usage — per-index scan counts with the catalog facts that decide whether an
- index can actually go. The half that is not in pg_stat_user_indexes is the point: a
- unique index backing a constraint enforces it without ever registering a scan, so advice
- derived from the counter alone tells somebody to drop their primary key. */
- .WithGeminiCompatibleTools()
- /* get_pg_table_bloat — the damage the vacuum reads above measure the cause of. The only
- read here whose headline number is an ESTIMATE, and the one whose contract is that it
- suppresses that number rather than captioning it when its inputs cannot be trusted. */
- .WithGeminiCompatibleTools()
- /* get_pg_session_states — the session side of the xmin horizon, and the one read here whose
- job includes REFUSING a causal claim. get_pg_xmin_horizon says a session is holding the
- horizon; this says which one, and — measured on a live instance — says when an
- idle-in-transaction session that looks identical is holding nothing at all, because a
- READ COMMITTED transaction that only read has already released its snapshot. */
- .WithGeminiCompatibleTools()
- /* #2659: these six shipped REGISTERED NOWHERE. They were implemented, documented, dispatched
- by the web API and counted in the instructions census, and an agent could not call one of
- them — the web dashboard could, which is why it went unnoticed. Registration here is
- per-class and explicit, with no assembly scan, so a tools class is reachable only if
- someone remembers this line and nothing failed when they did not.
- McpToolTypeRegistrationTests now derives the check by reflection instead of trusting it. */
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- /* get_pg_deadlocks / get_pg_deadlock_detail (#2661) - the reports themselves, out of the
- server log, rather than pg_stat_database's count. */
- .WithGeminiCompatibleTools()
- /* get_pg_log_events (#3601) - the classified log-event pipeline's read: errors, connections,
- lock waits and the recognised-only families, out of the same server log. */
- .WithGeminiCompatibleTools()
- /* get_pg_wait_trend / get_pg_query_duration_trend / get_pg_io_trend /
- get_pg_database_trend (#2663) - the PostgreSQL time series. Fourteen trend reads shipped
- and none worked on this engine. All four live on one tools class, so this line covers
- the later two as well - which is the only reason adding them needed no edit here. */
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- /* The windowed-trend siblings of the core data-read tools — get_memory_trend /
- get_perfmon_trend / get_file_io_trend / get_query_trend / get_query_duration_trend — the
- same names Lite and the Dashboard expose, over Darling's Postgres store (STORED reads of
- the collected memory / perfmon / file-io / query-stats series, no live hit). Each mirrors
- the viewer's proven chart read; the shape follows Lite where the SKUs diverge. */
- .WithGeminiCompatibleTools()
- /* The fleet-triage quick-win reads the fleet edition previously lacked — the alerts family
- (get_alert_history over config_alert_log, get_alert_settings over config_alert_settings,
- get_mute_rules via the service-side PgMuteRuleStore), the CURRENT-config snapshot trio
- (get_server_config / get_database_config / get_trace_flags — latest capture, the companion to
- the *_changes diff tools), and the health overview (get_server_summary + the daily rollup
- get_daily_summary and its #2484 range sibling get_daily_summary_range — the Performance
- Calendar's month grid — both folded through the shared DailyHealthBandCalculator). Same
- names Lite and the Dashboard expose, all STORED reads over Darling's Postgres store (no live
- hit). The blocking-trend / deadlock-trend / lock-wait-trend, memory-pressure-event, and
- wait-type siblings ride along on the existing blocking / memory-grant / core data-read
- classes above. */
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- .WithGeminiCompatibleTools()
- /* The cross-server fleet overview — get_fleet_overview (#1562) — the roll-up only the central
- store can serve, over the SHARED DarlingFleetReader that also powers the web /api/fleet and the
- WPF viewer's Overview (one reader, one banding). ADDITIVE alongside get_server_summary. */
- .WithGeminiCompatibleTools()
- /* The Availability Group topology — get_ag_health (#991) — every monitored server's view of the
- AGs it hosts, replicas plus per-database secondary state, over the SHARED DarlingAgReader that
- also powers the web /api/ag and the Availability Groups page (one reader, one banding). Like
- get_fleet_overview this is a cross-server read the central store makes possible. */
- .WithGeminiCompatibleTools()
- /* The fleet sweep reports read — get_sweep_reports (#3466) — the third cross-server read, and
- the one WITH MEMORY: the sweep timeline for a window, the newest sweep in full (mute header,
- would-have-paged ledger, instrument liveness), and the watch-item worklist, over the SAME
- FleetSweepStore presentation reads and FleetSweepPresentation builders the web /api/sweeps
- routes serve — one reader, one shape, the zero-drift rule get_fleet_overview and /api/fleet
- established, applied to the sweep rows. */
- .WithGeminiCompatibleTools()
- /* The system_health parse-on-read family — get_health_parser_cpu_tasks / _io_issues /
- _memory_broker / _memory_conditions / _memory_node_oom / _scheduler_issues /
- _severe_errors / _significant_waits / _system_health — the same names the Dashboard
- exposes. Where the Dashboard reads its server-side-parsed collect.HealthParser_*
- tables, these shred the raw
- system_health_events on read via the shared SystemHealthParser (Common) and gate with the
- service-side twin of the viewer's SystemEventSignificance, exactly as the viewer's System
- Events tab does — the same SIGNIFICANT warning set, no live hit. */
- .WithGeminiCompatibleTools()
- /* The Default Trace tool — get_default_trace_events — the same name the Dashboard exposes.
- Reads Darling's collected default_trace_events (the base table, no v_* view — like
- server_properties) and returns the SIGNIFICANT set via the shared
- DefaultTraceEventSignificance, the same significant-set gate the viewer's System Events
- surface uses; config-change events are excluded (the config-snapshot diff tools own them). */
- .WithGeminiCompatibleTools()
- /* The Custom Views v2 MANAGEMENT tools (#1563) — the one WRITE surface on this server:
- list_custom_views / get_custom_view / validate_custom_view / create_custom_view /
- update_custom_view / delete_custom_view / run_custom_view_panel. They CRUD the user-authored
- views in config.custom_views through the SAME CustomViewStore + ValidateDefinition + compose
- runner the web viewer's editor uses (no divergent second impl), and run back a composed panel's
- data for a self-test loop. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY
- config.custom_views (mirroring viewer's) — never the config pivot or the secret columns. */
- .WithGeminiCompatibleTools()
- /* The custom-alert-rule MANAGEMENT tools (#3285) - the second WRITE surface: list_custom_alert_rules
- / get_custom_alert_rule / validate_custom_alert_rule / create_custom_alert_rule /
- update_custom_alert_rule / delete_custom_alert_rule. They CRUD the user-authored threshold-alert
- rules in config.custom_alert_rules through the SAME CustomAlertRuleStore the web editor uses and the
- SAME CustomAlertRuleDefinition.TryParse the CustomAlertEvaluator applies when it loads a rule (no
- divergent second impl), validating every definition before it stores. The mcp role carries the
- narrow INSERT/UPDATE/DELETE grant on ONLY config.custom_alert_rules (granted under V116), never the
- config pivot or the secret columns. */
- .WithGeminiCompatibleTools()
- /* The server-onboarding WRITE tools — add_servers (BULK) / remove_server: an MCP client can stand up
- or tear down FLEET monitoring conversationally. The service-side twin of the Viewer's Add / Add-
- Multiple dialogs: add_servers validates each entry, probes the connection IN-PROCESS (the service
- holds the network path + credentials, so no test_connect command plane is needed), skips
- case-folded duplicates via the shared ServerIdHelper identity, DPAPI-encrypts the SQL password
- (the service identity, so it round-trips at collection time), and INSERTs config.config_monitored_
- servers mirroring StoreConfigProvider.SeedMonitoredServersAsync; remove_server DELETEs by the same
- resolver the read tools use. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY
- config.config_monitored_servers (the encrypted_password column stays SELECT-carved) — never the
- config pivot or a schema-wide write. */
- .WithGeminiCompatibleTools()
- /* #3898 D1: get_tool_guide serves the reading guides tools/list leaves out (the tails split off
- at McpToolGuide.Marker in WithGeminiCompatibleTools) and the cross-tool topics. Lite twin:
- McpToolGuideTools. */
- .WithGeminiCompatibleTools()
- /* Two call-tool filters, each registered ONCE and each covering every tool with no
- per-tool change — the seam that exists precisely so a decision about all ~147 reads
- is made in one place.
-
- The unknown-argument guard (#3870) runs FIRST and can refuse before dispatch: a call
- carrying an argument no tool parameter declares is answered with the refusal envelope
- naming the key and listing what the tool accepts, instead of being run with the key
- silently dropped. The SDK binds arguments by name and ignores the rest, so
- get_collection_log with a hallucinated status_filter returned two hundred unfiltered
- rows and nothing anywhere said a knob had been discarded — for a surface whose callers
- are language models, a silently dropped key is a confidently wrong answer. Shared with
- Lite from PerformanceMonitor.Common so both SKUs refuse identically.
-
- Optional GCF (Graph Compact Format) output runs after: when DARLING_OUTPUT_FORMAT=gcf
- it re-encodes each tool's JSON result as a GCF generic wire. Opt-in, lossless, and
- never larger than the JSON (see GcfCallToolFilter / GcfOutput). */
- .WithRequestFilters(filters => filters
- .AddCallToolFilter(McpUnknownArgumentGuard.Instance)
- .AddCallToolFilter(GcfCallToolFilter.Instance));
+ ConfigureMcpServices(builder.Services, declaredPeers);
_app = builder.Build();
- /* #2479 item 5: every gate below used to refuse silently, so "is my token wrong or my CIDR
- wrong" was answerable only from the client, which sees one opaque status code. One log per
- refusal is rate-limited per (gate, source) because this port is LAN-exposed on purpose and
- an exposed port meets a scanner eventually - see DarlingHttpRefusalLog for the shape and
- what it deliberately never writes. Created here, per started server, so a rebind starts
- with a clean budget rather than inheriting the previous listener's scan. */
- var refusals = new DarlingHttpRefusalLog();
-
- /* DNS-rebinding guard (#1648) — the FIRST middleware, in BOTH modes, mirroring the web host's
- #1576 fix. The loopback bind is tokenless by design (the network gates below install only in
- network mode), so a browser ON this host that loads attacker content could be rebound to
- 127.0.0.1:5152 and reach the MCP surface same-origin — and that surface is no longer read-only
- (custom-view CRUD, add_servers/remove_server, alert-config writes). The application/json content
- type does NOT save us: under a rebind the browser treats the request as same-origin, so no CORS
- preflight applies. Require the Host header to name an address we actually bind — a loopback
- name/IP or, in network mode, the configured listen IP. networkListenIp is null in loopback mode,
- so ONLY loopback Hosts pass there; a rebound foreign hostname is rejected 400 before the bearer
- check, the CIDR check, MapMcp, or any tool handler. */
- _app.Use(async (context, next) =>
- {
- if (!DarlingHostBinding.IsAllowedHost(context.Request.Host.Host, networkListenIp))
- {
- refusals.Report(
- _logger, "MCP", DarlingRefusalGate.HostAllowlist, StatusCodes.Status400BadRequest,
- context.Connection.RemoteIpAddress,
- $"the Host header '{DarlingHttpRefusalLog.Sanitize(context.Request.Host.Host)}' is not an address this endpoint binds"
- + " (a loopback name/IP, or mcp.network.listen when LAN-exposed)",
- DateTime.UtcNow);
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- return;
- }
-
- await next(context);
- });
-
- /* Access-control middleware — installed ONLY in network mode (Round-4 #6). The default/degraded
- loopback-only server stays byte-for-byte today's tokenless local MCP, so existing local clients
- keep working. Both run BEFORE MapMcp (D3-b: "first ... before any handler/handshake"): the
- unconditional constant-time bearer token FIRST (NO loopback exemption — in exposed mode even a
- local client must present the token; that IS the loopback guard against SSRF/sandboxed sockets),
- then the in-app CIDR check (loopback-exempt so the loopback bind's local clients are not 403'd,
- Round-4 #2 — it bounds WHO can route to the port, independent of the best-effort firewall). */
- if (networkMode)
- {
- var cidr = allowedCidr;
- var token = bearerToken;
-
- _app.Use(async (context, next) =>
- {
- /* Materialized once: StringValues.ToString() allocates, and the refusal path below needs
- the same header again to tell "no credential" from "wrong credential" (review catch on
- #2479). IsBearerTokenAuthorized keeps taking the raw header rather than returning what
- it parsed - its signature is pinned by DarlingMcpHostTests and DarlingHostBindingTests,
- and threading a result type through it to save one parse on an ALREADY-REFUSED request
- is not a trade worth making. */
- var authorization = context.Request.Headers.Authorization.ToString();
-
- if (!IsBearerTokenAuthorized(authorization, token))
- {
- /* THREE client states, never the token's value. Each one is a different next step
- for the operator, which is the whole point of logging this at all:
-
- no header -> a client that was never configured with a token
- header, not a Bearer -> a client configured wrong (Basic, a bare token, an
- empty "Bearer ") - it IS sending something
- a Bearer that misses -> a token that does not match this endpoint's
-
- Review catch on #2479: ExtractBearerToken returns null for the first TWO, so
- testing only it reported "nothing was presented" about a client that presented
- a malformed header - collapsing precisely the ambiguity this exists to resolve.
- None of the three says anything about what the token IS. */
- refusals.Report(
- _logger, "MCP", DarlingRefusalGate.Token, StatusCodes.Status401Unauthorized,
- context.Connection.RemoteIpAddress,
- DescribeBearerRefusal(authorization),
- DateTime.UtcNow);
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- context.Response.Headers.WWWAuthenticate = "Bearer";
- return;
- }
-
- await next(context);
- });
-
- _app.Use(async (context, next) =>
- {
- if (!IsRemoteAddressAllowed(context.Connection.RemoteIpAddress, cidr))
- {
- refusals.Report(
- _logger, "MCP", DarlingRefusalGate.SourceCidr, StatusCodes.Status403Forbidden,
- context.Connection.RemoteIpAddress,
- $"its address is outside mcp.network.allowFrom ({cidr})",
- DateTime.UtcNow);
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
- return;
- }
-
- await next(context);
- });
- }
-
- _app.MapMcp();
-
- /* #3898 D7: /core, the same endpoint narrowed to DarlingCoreToolProfile's closure via
- ConfigureSessionOptions above. Mapped on the SAME _app after the SAME middleware (the Host-header
- guard, and in network mode the bearer-token and CIDR checks) — nothing about this route is
- exempt from any gate above. / is unchanged and keeps serving every tool, for good. */
- _app.MapMcp("/core");
+ ConfigurePipeline(_app, networkMode, networkListenIp, allowedCidr, bearerToken);
/* #2389: name the authority for each half of what is being started. enabled/port come from
whichever plane the supervisor resolved; listen/allowFrom/token are always darling.json. */
@@ -1033,6 +594,479 @@ serving until StopServerAsync (toggle-off, port change, or shutdown). */
}
}
+ ///
+ /// Registers the MCP server, its stateless HTTP transport, every tool class, and the two call-tool
+ /// filters — the same registration TryStartServerAsync used to build inline. Extracted (#4128)
+ /// so a live-HTTP test builds the SAME server (including /core's ConfigureSessionOptions
+ /// narrowing and its tools/list) against a test-constructed , instead of
+ /// a hand-copied second registration that could silently drift from production. The production call
+ /// site passes exactly this snapshot — see TryStartServerAsync.
+ /// Every line below is identical to before the extraction; only the receiver (builder.Services
+ /// there vs. the parameter here) changes.
+ ///
+ internal static void ConfigureMcpServices(IServiceCollection services, DarlingPeerDirectory.Snapshot declaredPeers)
+ {
+ services
+ .AddMcpServer(options =>
+ {
+ options.ServerInfo = new()
+ {
+ Name = "PerformanceMonitorDarling",
+ Version = "1.0.0"
+ };
+ options.ServerInstructions = DarlingMcpInstructions.Build(declaredPeers);
+ })
+ /* Stateless mode: each request is self-contained (no Mcp-Session-Id round-trip).
+ Required for clients like Google Antigravity that don't echo the session id,
+ which otherwise connect but list zero tools (issue #1074).
+
+ ConfigureSessionOptions is the /core profile's ONLY hook (#3898 D7): in Stateless mode the
+ SDK invokes it on every request with that request's HttpContext, after ToolCollection is
+ already populated from every tool class registered below. A /core request gets
+ ToolCollection replaced with DarlingCoreToolProfile's closure; every other path (in
+ practice, just /) is untouched and keeps the full set. This is a REAL subset, not a listing
+ filter: the SDK's tools/call dispatch checks the same ToolCollection before falling back to
+ any handler, and Darling registers no fallback CallToolHandler, so a tools/call for a tool
+ outside the closure gets the SDK's own "unknown tool" error on /core. Security posture is
+ inherited for free: this callback runs from inside the MCP transport, AFTER every _app.Use
+ middleware below (Host-header guard, bearer token, CIDR) — a /core request is refused there
+ exactly as a / request would be, before this callback, or MapMcp, ever runs.
+ The subset holds only in Stateless mode, where this callback runs on every request. A stateful
+ session is looked up by its id alone, not by route, so one opened on / could call any tool on
+ /core. HostHeaderGuardTests pins Stateless while /core is mapped. The instructions change too:
+ the shared text counts and describes every tool on /, so /core leads with a note saying what
+ it serves. */
+ .WithHttpTransport(options =>
+ {
+ options.Stateless = true;
+ options.ConfigureSessionOptions = (context, sessionOptions, _) =>
+ {
+ if (DarlingCoreToolProfile.IsCorePath(context.Request.Path))
+ {
+ sessionOptions.ToolCollection = DarlingCoreToolProfile.FilterToolCollection(sessionOptions.ToolCollection);
+ sessionOptions.ServerInstructions = DarlingCoreToolProfile.CoreInstructions(sessionOptions.ServerInstructions);
+ }
+
+ return Task.CompletedTask;
+ };
+ })
+ /* WithGeminiCompatibleTools (not the SDK's WithTools) rewrites parameter schemas into
+ the subset Gemini/Antigravity accepts — collapsing nullable type unions and
+ dropping the default keyword. The companion to stateless transport for issue #1074. */
+ .WithGeminiCompatibleTools()
+ /* The five plan-analysis tools (analyze_query_plan / analyze_procedure_plan /
+ analyze_query_store_plan / analyze_plan_xml / get_plan_xml) — the same names the
+ Dashboard and Lite expose, fetching the collectors' STORED plan XML from Postgres
+ (no live monitored-server hit) and running the SHARED PlanAnalysis engine. */
+ .WithGeminiCompatibleTools()
+ /* The core data-read tools (resource metrics, query performance, discovery/health —
+ get_cpu_utilization / get_wait_stats / get_wait_trend / get_memory_stats /
+ get_memory_clerks / get_file_io_stats / get_tempdb_trend / get_perfmon_stats /
+ get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top /
+ list_servers / get_collection_health / get_server_properties), the same names Lite
+ and the Dashboard expose, over Darling's Postgres store (STORED reads, no live hit).
+ These are the tools the analysis findings' next_tools recommendations point at. */
+ .WithGeminiCompatibleTools()
+ /* get_query_store_regressions (#2484) — the viewer's Query Store Regressions tab. Every
+ other Query Store read answers what is EXPENSIVE; this answers what got WORSE, which is
+ not derivable from the first (the costliest query is usually the one that always was).
+ A STORED read over the same query_store_stats the tools above read. */
+ .WithGeminiCompatibleTools()
+ /* get_query_store_clutter (#3797) — the Query Store CLUTTER view: per-database read cost
+ (the collection_log fan-out rollup), plan churn (raw query_store_stats plan identities) and
+ configuration (query_store_health), plus ONE per-server overhead block (the non-sleep QDS_*
+ wait deltas and MEMORYCLERK_QUERYDISKSTORE). Composed from rows the collectors already
+ write — not a new query against the target. Darling-only for now: every input exists on
+ Lite too, so the twin is a port, not a SKU boundary (CrossAppMcpToolInventoryPinTests). */
+ .WithGeminiCompatibleTools()
+ /* get_query_heatmap (#2484) — the viewer's Query Heatmap tab. The interactive plot is
+ desktop-only by design; the READ behind it is not, and a bucketed table is the same
+ answer. It is the only query read with a TIME axis: the rankings above cannot show that
+ a window had a quiet half and a bad half. A STORED read over the same query_stats. */
+ .WithGeminiCompatibleTools()
+ /* The diagnostic-depth data-read tools (blocking/deadlocks, sessions, config-history,
+ index/object) — get_blocking / get_deadlocks / get_deadlock_detail /
+ get_blocked_process_xml, get_session_stats / get_active_queries / get_waiting_tasks,
+ get_server_config_changes / get_database_config_changes / get_trace_flag_changes /
+ get_database_scoped_config, get_table_index_sizes / get_index_usage / get_object_locking /
+ get_database_sizes — the same names Lite and the Dashboard expose, over Darling's Postgres
+ store (STORED reads, no live hit). Result shapes follow Lite where the two SKUs diverge. */
+ .WithGeminiCompatibleTools()
+ /* #2028 get_plan_corrections — automatic plan correction activity + per-database
+ FORCE_LAST_GOOD_PLAN enablement, the one collected table that previously had no
+ agent-readable path at all. Twin registered in Lite's host. */
+ .WithGeminiCompatibleTools()
+ /* #2029 get_pvs_stats — the ADR persistent version store, previously reachable only
+ indirectly (alert knobs + compose measures). Twin registered in Lite's host. */
+ .WithGeminiCompatibleTools()
+ /* #2068 get_store_metrics — the monitoring store's OWN hourly self-metrics series
+ (per-hypertable size/compression, payload-dimension sizes + row counts, whole-store
+ size + enabled-server count) for capacity forecasting. Darling-only: a single-server
+ edition has no central store to measure, so no Lite twin. */
+ .WithGeminiCompatibleTools()
+ /* #3021 get_store_log — the store's OWN server-log census, the second self-monitoring
+ surface beside get_store_metrics. */
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ /* #2880 get_collector_stall_probes - the out-of-band server-wide wait samples taken
+ while one of OUR collectors was stalled mid-read. Darling-only: the arm is installed by
+ DarlingCollectorRunner's server-scoped path, which Lite's runner does not have. */
+ .WithGeminiCompatibleTools()
+ /* #3398 get_oversized_plan_backlog - the V121 worklist of cached plans the capture cap
+ declined, and what the out-of-band sweep has done about each one. Darling-only: the
+ sweep is a fleet-level errand on the headless worker's own cadence, which Lite's
+ single-instance runner has no counterpart of. */
+ .WithGeminiCompatibleTools()
+ /* #1496 get_long_query_completions — the opt-in long-query completion trace (rpc/batch over
+ the duration threshold + attentions), over Darling's Postgres store (STORED read). */
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ /* The resource-contention + jobs data-read tools — get_latch_stats / get_spinlock_stats,
+ get_resource_semaphore / get_memory_grants, get_plan_cache_bloat / get_cpu_scheduler_pressure,
+ get_running_jobs — the same names Lite and the Dashboard expose, over Darling's Postgres store
+ (STORED reads of the collected latch/spinlock/memory-grant/plan-cache/cpu-scheduler/running-job
+ snapshots, no live hit). The Dashboard-only CASE enrichment (latch severity/description/
+ recommendation, spinlock description) and the #1410 client-side classifications (plan-cache
+ bloat_level, cpu-scheduler pressure_level) are reproduced service-side so the full result shape
+ is served. Per-second rates divide by each row's stored sample_interval_seconds (V127, #3540),
+ falling back to the LAG interval only for pre-V127 rows, and are null when the latest interval
+ was unknowable rather than 0. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_wait_stats — PostgreSQL wait events for an Aurora target, paired with the
+ pg_wait_stats collector. A separate tool from get_wait_stats rather than a widened
+ one: PostgreSQL's waits are a two-level type/event taxonomy with no signal-wait
+ concept, reported in microseconds, so the two engines cannot share a result shape
+ without lying about a unit or emitting mostly-null columns. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_cpu_utilization — instance-level CPU for a PostgreSQL/Aurora target (#2719),
+ paired with the pg_cpu_utilization collector. Sourced from AWS Performance Insights
+ rather than a database connection, so it sits beside the wait tools rather than the
+ activity ones: a gauge over time, like SQL Server's own CPU read, not a ranked list. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_top_queries — PostgreSQL query shapes by total time, paired with the
+ pg_statement_stats collector. Carries Aurora's I/O source split and per-statement
+ peak memory, neither of which the SQL Server tools have an equivalent for. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_plans — the plan itself, not a pointer to one (#2567). Registered beside the
+ statement tools because that is the join: a plan is read alongside the statement it
+ belongs to, on query_id. Carries get_pg_plan_capture_readiness too (#3070): whether the
+ target can capture a plan at all, facet by facet with the remedy for each, which is the
+ read somebody needs the moment the plans one comes back empty. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_logging_audit (#3607) - the rest of the logging surface, in readiness's shape:
+ log_lock_waits, log_temp_files, log_autovacuum_min_duration, log_checkpoints,
+ log_connections / log_disconnections and log_min_duration_statement, each judged from
+ the stored pg_server_config snapshot with what it unlocks, the recommended value and its
+ cost, and the remedy in the hosting flavour's syntax. Registered beside the plan tools
+ because it is the other half of one onboarding question - is this target telling us
+ everything it could - and lists plan capture's own settings with a pointer to the
+ readiness read rather than judging them twice. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_wraparound_risk — XID/MultiXact freeze headroom, the highest-consequence
+ PostgreSQL signal and one with no SQL Server counterpart. Not Aurora-gated. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_xmin_horizon — why vacuum reclaims nothing, attributed to one of four causes
+ that are indistinguishable by symptom and need different fixes. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_replication_slots — the other half of the abandoned-slot story. The xmin tool
+ reports a slot pinning the horizon; this one reports the WAL it is retaining, which is
+ unbounded by default and fills the volume regardless of what vacuum is doing. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_autovacuum_health — which tables autovacuum is not keeping up with, ranked by
+ how far past each table's OWN threshold it is. The ratio is the whole tool: a
+ dead-tuple count is not comparable between a 50-million-row table and a 10,000-row
+ one, and the threshold is what makes it so. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_io_stats — I/O attributed to who/what/why rather than to a file. The context
+ dimension has no SQL Server counterpart and is what separates a buffer-pool miss that
+ more memory would fix from a ring-buffered sequential scan that it would not. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_blocking — who is blocked by whom, assembled from the stored edge list into chains
+ with the ROOT attributed. The one PostgreSQL read whose caveat has to travel WITH the
+ answer: SQL Server's blocked-process report is engine-recorded, this is periodically
+ sampled, so "no blocking" here means "none was sampled" and the tool reports its own
+ capture count so that distinction cannot be lost. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_database_stats — four questions off one cluster-wide view: temp-file spills (the
+ PostgreSQL answer to "why is this query slow" that no other read here can give on a stock
+ target), the buffer-cache hit ratio, a server-recorded deadlock count, and the
+ commit/rollback split. The one read whose reset handling is part of its contract: a
+ statistics reset is reported as a reset rather than surfacing as a negative rate. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_index_usage — per-index scan counts with the catalog facts that decide whether an
+ index can actually go. The half that is not in pg_stat_user_indexes is the point: a
+ unique index backing a constraint enforces it without ever registering a scan, so advice
+ derived from the counter alone tells somebody to drop their primary key. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_table_bloat — the damage the vacuum reads above measure the cause of. The only
+ read here whose headline number is an ESTIMATE, and the one whose contract is that it
+ suppresses that number rather than captioning it when its inputs cannot be trusted. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_session_states — the session side of the xmin horizon, and the one read here whose
+ job includes REFUSING a causal claim. get_pg_xmin_horizon says a session is holding the
+ horizon; this says which one, and — measured on a live instance — says when an
+ idle-in-transaction session that looks identical is holding nothing at all, because a
+ READ COMMITTED transaction that only read has already released its snapshot. */
+ .WithGeminiCompatibleTools()
+ /* #2659: these six shipped REGISTERED NOWHERE. They were implemented, documented, dispatched
+ by the web API and counted in the instructions census, and an agent could not call one of
+ them — the web dashboard could, which is why it went unnoticed. Registration here is
+ per-class and explicit, with no assembly scan, so a tools class is reachable only if
+ someone remembers this line and nothing failed when they did not.
+ McpToolTypeRegistrationTests now derives the check by reflection instead of trusting it. */
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ /* get_pg_deadlocks / get_pg_deadlock_detail (#2661) - the reports themselves, out of the
+ server log, rather than pg_stat_database's count. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_log_events (#3601) - the classified log-event pipeline's read: errors, connections,
+ lock waits and the recognised-only families, out of the same server log. */
+ .WithGeminiCompatibleTools()
+ /* get_pg_wait_trend / get_pg_query_duration_trend / get_pg_io_trend /
+ get_pg_database_trend (#2663) - the PostgreSQL time series. Fourteen trend reads shipped
+ and none worked on this engine. All four live on one tools class, so this line covers
+ the later two as well - which is the only reason adding them needed no edit here. */
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ /* The windowed-trend siblings of the core data-read tools — get_memory_trend /
+ get_perfmon_trend / get_file_io_trend / get_query_trend / get_query_duration_trend — the
+ same names Lite and the Dashboard expose, over Darling's Postgres store (STORED reads of
+ the collected memory / perfmon / file-io / query-stats series, no live hit). Each mirrors
+ the viewer's proven chart read; the shape follows Lite where the SKUs diverge. */
+ .WithGeminiCompatibleTools()
+ /* The fleet-triage quick-win reads the fleet edition previously lacked — the alerts family
+ (get_alert_history over config_alert_log, get_alert_settings over config_alert_settings,
+ get_mute_rules via the service-side PgMuteRuleStore), the CURRENT-config snapshot trio
+ (get_server_config / get_database_config / get_trace_flags — latest capture, the companion to
+ the *_changes diff tools), and the health overview (get_server_summary + the daily rollup
+ get_daily_summary and its #2484 range sibling get_daily_summary_range — the Performance
+ Calendar's month grid — both folded through the shared DailyHealthBandCalculator). Same
+ names Lite and the Dashboard expose, all STORED reads over Darling's Postgres store (no live
+ hit). The blocking-trend / deadlock-trend / lock-wait-trend, memory-pressure-event, and
+ wait-type siblings ride along on the existing blocking / memory-grant / core data-read
+ classes above. */
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ .WithGeminiCompatibleTools()
+ /* The cross-server fleet overview — get_fleet_overview (#1562) — the roll-up only the central
+ store can serve, over the SHARED DarlingFleetReader that also powers the web /api/fleet and the
+ WPF viewer's Overview (one reader, one banding). ADDITIVE alongside get_server_summary. */
+ .WithGeminiCompatibleTools()
+ /* The Availability Group topology — get_ag_health (#991) — every monitored server's view of the
+ AGs it hosts, replicas plus per-database secondary state, over the SHARED DarlingAgReader that
+ also powers the web /api/ag and the Availability Groups page (one reader, one banding). Like
+ get_fleet_overview this is a cross-server read the central store makes possible. */
+ .WithGeminiCompatibleTools()
+ /* The fleet sweep reports read — get_sweep_reports (#3466) — the third cross-server read, and
+ the one WITH MEMORY: the sweep timeline for a window, the newest sweep in full (mute header,
+ would-have-paged ledger, instrument liveness), and the watch-item worklist, over the SAME
+ FleetSweepStore presentation reads and FleetSweepPresentation builders the web /api/sweeps
+ routes serve — one reader, one shape, the zero-drift rule get_fleet_overview and /api/fleet
+ established, applied to the sweep rows. */
+ .WithGeminiCompatibleTools()
+ /* The system_health parse-on-read family — get_health_parser_cpu_tasks / _io_issues /
+ _memory_broker / _memory_conditions / _memory_node_oom / _scheduler_issues /
+ _severe_errors / _significant_waits / _system_health — the same names the Dashboard
+ exposes. Where the Dashboard reads its server-side-parsed collect.HealthParser_*
+ tables, these shred the raw
+ system_health_events on read via the shared SystemHealthParser (Common) and gate with the
+ service-side twin of the viewer's SystemEventSignificance, exactly as the viewer's System
+ Events tab does — the same SIGNIFICANT warning set, no live hit. */
+ .WithGeminiCompatibleTools()
+ /* The Default Trace tool — get_default_trace_events — the same name the Dashboard exposes.
+ Reads Darling's collected default_trace_events (the base table, no v_* view — like
+ server_properties) and returns the SIGNIFICANT set via the shared
+ DefaultTraceEventSignificance, the same significant-set gate the viewer's System Events
+ surface uses; config-change events are excluded (the config-snapshot diff tools own them). */
+ .WithGeminiCompatibleTools()
+ /* The Custom Views v2 MANAGEMENT tools (#1563) — the one WRITE surface on this server:
+ list_custom_views / get_custom_view / validate_custom_view / create_custom_view /
+ update_custom_view / delete_custom_view / run_custom_view_panel. They CRUD the user-authored
+ views in config.custom_views through the SAME CustomViewStore + ValidateDefinition + compose
+ runner the web viewer's editor uses (no divergent second impl), and run back a composed panel's
+ data for a self-test loop. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY
+ config.custom_views (mirroring viewer's) — never the config pivot or the secret columns. */
+ .WithGeminiCompatibleTools()
+ /* The custom-alert-rule MANAGEMENT tools (#3285) - the second WRITE surface: list_custom_alert_rules
+ / get_custom_alert_rule / validate_custom_alert_rule / create_custom_alert_rule /
+ update_custom_alert_rule / delete_custom_alert_rule. They CRUD the user-authored threshold-alert
+ rules in config.custom_alert_rules through the SAME CustomAlertRuleStore the web editor uses and the
+ SAME CustomAlertRuleDefinition.TryParse the CustomAlertEvaluator applies when it loads a rule (no
+ divergent second impl), validating every definition before it stores. The mcp role carries the
+ narrow INSERT/UPDATE/DELETE grant on ONLY config.custom_alert_rules (granted under V116), never the
+ config pivot or the secret columns. */
+ .WithGeminiCompatibleTools()
+ /* The server-onboarding WRITE tools — add_servers (BULK) / remove_server: an MCP client can stand up
+ or tear down FLEET monitoring conversationally. The service-side twin of the Viewer's Add / Add-
+ Multiple dialogs: add_servers validates each entry, probes the connection IN-PROCESS (the service
+ holds the network path + credentials, so no test_connect command plane is needed), skips
+ case-folded duplicates via the shared ServerIdHelper identity, DPAPI-encrypts the SQL password
+ (the service identity, so it round-trips at collection time), and INSERTs config.config_monitored_
+ servers mirroring StoreConfigProvider.SeedMonitoredServersAsync; remove_server DELETEs by the same
+ resolver the read tools use. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY
+ config.config_monitored_servers (the encrypted_password column stays SELECT-carved) — never the
+ config pivot or a schema-wide write. */
+ .WithGeminiCompatibleTools()
+ /* #3898 D1: get_tool_guide serves the reading guides tools/list leaves out (the tails split off
+ at McpToolGuide.Marker in WithGeminiCompatibleTools) and the cross-tool topics. Lite twin:
+ McpToolGuideTools. */
+ .WithGeminiCompatibleTools()
+ /* Two call-tool filters, each registered ONCE and each covering every tool with no
+ per-tool change — the seam that exists precisely so a decision about all ~147 reads
+ is made in one place.
+
+ The unknown-argument guard (#3870) runs FIRST and can refuse before dispatch: a call
+ carrying an argument no tool parameter declares is answered with the refusal envelope
+ naming the key and listing what the tool accepts, instead of being run with the key
+ silently dropped. The SDK binds arguments by name and ignores the rest, so
+ get_collection_log with a hallucinated status_filter returned two hundred unfiltered
+ rows and nothing anywhere said a knob had been discarded — for a surface whose callers
+ are language models, a silently dropped key is a confidently wrong answer. Shared with
+ Lite from PerformanceMonitor.Common so both SKUs refuse identically.
+
+ Optional GCF (Graph Compact Format) output runs after: when DARLING_OUTPUT_FORMAT=gcf
+ it re-encodes each tool's JSON result as a GCF generic wire. Opt-in, lossless, and
+ never larger than the JSON (see GcfCallToolFilter / GcfOutput). */
+ .WithRequestFilters(filters => filters
+ .AddCallToolFilter(McpUnknownArgumentGuard.Instance)
+ .AddCallToolFilter(GcfCallToolFilter.Instance));
+ }
+
+ ///
+ /// Everything AFTER builder.Build(): the Host-allowlist/DNS-rebinding guard (both modes), the
+ /// network-mode bearer-token + CIDR middleware, then MapMcp() and MapMcp("/core").
+ /// Extracted (#4128) so a live-HTTP test can build the SAME pipeline against a TestServer instead
+ /// of a second, hand-copied one that could silently drift from production. The production call site
+ /// passes exactly these values, in exactly this order — see TryStartServerAsync. Instance method,
+ /// not static: the gates read _logger, exactly as before the extraction — only the receiver
+ /// (this vs. a test-constructed instance) changes.
+ ///
+ internal void ConfigurePipeline(
+ WebApplication app,
+ bool networkMode,
+ IPAddress? networkListenIp,
+ IPNetwork allowedCidr,
+ string bearerToken)
+ {
+ /* #2479 item 5: every gate below used to refuse silently, so "is my token wrong or my CIDR
+ wrong" was answerable only from the client, which sees one opaque status code. One log per
+ refusal is rate-limited per (gate, source) because this port is LAN-exposed on purpose and
+ an exposed port meets a scanner eventually - see DarlingHttpRefusalLog for the shape and
+ what it deliberately never writes. Created here, per started server, so a rebind starts
+ with a clean budget rather than inheriting the previous listener's scan. */
+ var refusals = new DarlingHttpRefusalLog();
+
+ /* DNS-rebinding guard (#1648) — the FIRST middleware, in BOTH modes, mirroring the web host's
+ #1576 fix. The loopback bind is tokenless by design (the network gates below install only in
+ network mode), so a browser ON this host that loads attacker content could be rebound to
+ 127.0.0.1:5152 and reach the MCP surface same-origin — and that surface is no longer read-only
+ (custom-view CRUD, add_servers/remove_server, alert-config writes). The application/json content
+ type does NOT save us: under a rebind the browser treats the request as same-origin, so no CORS
+ preflight applies. Require the Host header to name an address we actually bind — a loopback
+ name/IP or, in network mode, the configured listen IP. networkListenIp is null in loopback mode,
+ so ONLY loopback Hosts pass there; a rebound foreign hostname is rejected 400 before the bearer
+ check, the CIDR check, MapMcp, or any tool handler. */
+ app.Use(async (context, next) =>
+ {
+ if (!DarlingHostBinding.IsAllowedHost(context.Request.Host.Host, networkListenIp))
+ {
+ refusals.Report(
+ _logger, "MCP", DarlingRefusalGate.HostAllowlist, StatusCodes.Status400BadRequest,
+ context.Connection.RemoteIpAddress,
+ $"the Host header '{DarlingHttpRefusalLog.Sanitize(context.Request.Host.Host)}' is not an address this endpoint binds"
+ + " (a loopback name/IP, or mcp.network.listen when LAN-exposed)",
+ DateTime.UtcNow);
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ await next(context);
+ });
+
+ /* Access-control middleware — installed ONLY in network mode (Round-4 #6). The default/degraded
+ loopback-only server stays byte-for-byte today's tokenless local MCP, so existing local clients
+ keep working. Both run BEFORE MapMcp (D3-b: "first ... before any handler/handshake"): the
+ unconditional constant-time bearer token FIRST (NO loopback exemption — in exposed mode even a
+ local client must present the token; that IS the loopback guard against SSRF/sandboxed sockets),
+ then the in-app CIDR check (loopback-exempt so the loopback bind's local clients are not 403'd,
+ Round-4 #2 — it bounds WHO can route to the port, independent of the best-effort firewall). */
+ if (networkMode)
+ {
+ var cidr = allowedCidr;
+ var token = bearerToken;
+
+ app.Use(async (context, next) =>
+ {
+ /* Materialized once: StringValues.ToString() allocates, and the refusal path below needs
+ the same header again to tell "no credential" from "wrong credential" (review catch on
+ #2479). IsBearerTokenAuthorized keeps taking the raw header rather than returning what
+ it parsed - its signature is pinned by DarlingMcpHostTests and DarlingHostBindingTests,
+ and threading a result type through it to save one parse on an ALREADY-REFUSED request
+ is not a trade worth making. */
+ var authorization = context.Request.Headers.Authorization.ToString();
+
+ if (!IsBearerTokenAuthorized(authorization, token))
+ {
+ /* THREE client states, never the token's value. Each one is a different next step
+ for the operator, which is the whole point of logging this at all:
+
+ no header -> a client that was never configured with a token
+ header, not a Bearer -> a client configured wrong (Basic, a bare token, an
+ empty "Bearer ") - it IS sending something
+ a Bearer that misses -> a token that does not match this endpoint's
+
+ Review catch on #2479: ExtractBearerToken returns null for the first TWO, so
+ testing only it reported "nothing was presented" about a client that presented
+ a malformed header - collapsing precisely the ambiguity this exists to resolve.
+ None of the three says anything about what the token IS. */
+ refusals.Report(
+ _logger, "MCP", DarlingRefusalGate.Token, StatusCodes.Status401Unauthorized,
+ context.Connection.RemoteIpAddress,
+ DescribeBearerRefusal(authorization),
+ DateTime.UtcNow);
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ context.Response.Headers.WWWAuthenticate = "Bearer";
+ return;
+ }
+
+ await next(context);
+ });
+
+ app.Use(async (context, next) =>
+ {
+ if (!IsRemoteAddressAllowed(context.Connection.RemoteIpAddress, cidr))
+ {
+ refusals.Report(
+ _logger, "MCP", DarlingRefusalGate.SourceCidr, StatusCodes.Status403Forbidden,
+ context.Connection.RemoteIpAddress,
+ $"its address is outside mcp.network.allowFrom ({cidr})",
+ DateTime.UtcNow);
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ await next(context);
+ });
+ }
+
+ app.MapMcp();
+
+ /* #3898 D7: /core, the same endpoint narrowed to DarlingCoreToolProfile's closure via
+ ConfigureSessionOptions above. Mapped on the SAME _app after the SAME middleware (the Host-header
+ guard, and in network mode the bearer-token and CIDR checks) — nothing about this route is
+ exempt from any gate above. / is unchanged and keeps serving every tool, for good. */
+ app.MapMcp("/core");
+ }
+
/* ---------------------------------------------------------------------------------------------------
Pure decision functions (darling-network-endpoints, D3). Factored out of the Kestrel/middleware
wiring so they are unit-testable without a running server or a logger; the caller maps the reason to
From d8d7292b82d6340dad3ab3caf8962a7c69931154 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:11:57 -0400
Subject: [PATCH 2/3] 4128b: read TestServer's response stream as it stands (it
can't seek), and check /core's instructions note in the initialize result,
where the instructions travel
---
.../DarlingMcpHostGateLiveTests.cs | 26 ++++++++++++++-----
1 file changed, 19 insertions(+), 7 deletions(-)
diff --git a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
index e1f321db6b..df152e077a 100644
--- a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
@@ -138,11 +138,11 @@ private static Task SendRaw(TestServer server, string path, string
{
c.Request.Headers.Authorization = $"Bearer {bearer}";
}
-
- c.Response.Body = new System.IO.MemoryStream();
});
- ctx.Response.Body.Seek(0, System.IO.SeekOrigin.Begin);
+ /* TestServer hands back its own response stream (a reader over what the pipeline wrote), and it
+ can't seek: read it from where it stands. Replacing Response.Body in the configure callback has no
+ effect, because TestServer installs its own response feature. */
using var reader = new System.IO.StreamReader(ctx.Response.Body);
var body = await reader.ReadToEndAsync();
return (ctx.Response.StatusCode, body);
@@ -227,12 +227,24 @@ public async Task Core_ToolsList_IsExactlyThePinnedSet_WithTheCoreInstructionsNo
Assert.Equal(DarlingCoreToolProfile.Closure.ToHashSet(), toolNames);
- var instructions = payload.TryGetProperty("instructions", out var instructionsElement)
- ? instructionsElement.GetString() ?? ""
- : "";
- Assert.Contains("/core", instructions, StringComparison.OrdinalIgnoreCase);
+ /* The instructions travel in the initialize result, not tools/list. A /core session's lead with
+ DarlingCoreToolProfile.CoreNote; the full set's do not. */
+ var (coreInitStatus, coreInitBody) = await SendJsonRpcAsync(server, "/core", "localhost", IPAddress.Loopback,
+ "initialize", InitializeParams, bearer: null);
+ Assert.Equal(StatusCodes.Status200OK, coreInitStatus);
+ var coreInstructions = ReadJsonRpcResult(coreInitBody).GetProperty("instructions").GetString() ?? "";
+ Assert.StartsWith(DarlingCoreToolProfile.CoreNote, coreInstructions, StringComparison.Ordinal);
+
+ var (fullInitStatus, fullInitBody) = await SendJsonRpcAsync(server, "/", "localhost", IPAddress.Loopback,
+ "initialize", InitializeParams, bearer: null);
+ Assert.Equal(StatusCodes.Status200OK, fullInitStatus);
+ var fullInstructions = ReadJsonRpcResult(fullInitBody).GetProperty("instructions").GetString() ?? "";
+ Assert.False(fullInstructions.StartsWith(DarlingCoreToolProfile.CoreNote, StringComparison.Ordinal));
}
+ private const string InitializeParams =
+ "{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"gate-test\",\"version\":\"1\"}}";
+
/// A tools/call on /core for a tool OUTSIDE its pinned set gets the SDK's own
/// unknown-tool error — Darling registers no fallback CallToolHandler, so this is a real subset,
/// not a listing filter (#3898 D7).
From 7ca8bb748d6853de6a27c14eb9a10491578ff16b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:13:28 -0400
Subject: [PATCH 3/3] 4128b: TestServer.CreateClient is prose, not a cref
---
Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
index df152e077a..e54d09c6f3 100644
--- a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
@@ -40,7 +40,7 @@ namespace Darling.Tests;
/// The gates read context.Connection.RemoteIpAddress, so requests go through
/// where that is needed (the Host-header cases); the MCP conversation
/// itself (initialize / tools/list / tools/call) is spoken as plain JSON-RPC HTTP POSTs over
-/// , since the SDK's transport reads the body, not the connection.
+/// TestServer.CreateClient, since the SDK's transport reads the body, not the connection.
///
public sealed class DarlingMcpHostGateLiveTests
{