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 {