From 541e92f7b7ae015638039fc96e11f03e11a35010 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 23 Jun 2026 23:18:12 -0700 Subject: [PATCH 1/2] Consolidate conformance test servers into a single shared host Follow-up cleanups to the conformance test infrastructure, squashed from three working commits. Serve both conformance lifecycles from one server. The conformance tests previously ran two ConformanceServer instances: a stateful one (ConformanceServerFixture, served at the default endpoint) and a stateless one that each test spun up on its own fixed port (a StatelessMrtrConformanceServerFixture for MRTR, plus a StatelessConformanceServer helper that the SEP-2243 and caching tests started per-test on hand-picked port ranges). Differentiating the stateful server per target framework while pinning the stateless servers to fixed ports was inconsistent and prone to TCP TIME_WAIT conflicts on Windows. Borrow TestSseServer's HandleStatelessMcp trick to host both lifecycles on a single Kestrel port: the ConformanceServer now maps the stateful MCP server at "/" and a separate stateless MCP server (its own ServiceCollection/ ApplicationBuilder so the isolated HttpServerTransportOptions.Stateless value does not collide) at "/stateless". The --stateless/MCP_CONFORMANCE_STATELESS switch is gone since one process serves everything. A single shared ConformanceServerFixture (in its own file) now exposes ServerUrl ("/") and StatelessServerUrl ("/stateless"), and both ServerConformanceTests and CachingConformanceTests consume it via [Collection(nameof(ConformanceServerCollection))]. This centralizes the per-framework port-binding logic, removes the now-redundant StatelessMrtrConformanceServerFixture and StatelessConformanceServer helper, and means no test restarts a server on a fixed port. Also drop the stray space in the commented-out [InlineData("http-custom-headers")] line in ClientConformanceTests so it follows the usual convention for code meant to be uncommented later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CachingConformanceTests.cs | 112 +------------ .../ClientConformanceTests.cs | 2 +- .../ConformanceServerFixture.cs | 104 ++++++++++++ .../ServerConformanceTests.cs | 150 ++---------------- .../Program.cs | 78 ++++++--- 5 files changed, 181 insertions(+), 265 deletions(-) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 38e503258..8a2bd5f50 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -1,104 +1,7 @@ -using System.Diagnostics; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; -/// -/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the -/// SEP-2549 "caching" conformance scenario (new in the 2026-07-28 protocol revision) -/// requires. Started on demand (so it is not bound -/// when the caching test is skipped) and torn down via . Uses a -/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so -/// the two can run in parallel without TCP conflicts. -/// -internal sealed class StatelessConformanceServer : IAsyncDisposable -{ - // Use different ports for each target framework to allow parallel execution across the - // multi-targeted test processes, offset from a caller-supplied base port so independent - // stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0, - // net9.0 -> +1, net8.0 -> +2. - private static int GetPortForTargetFramework(int basePort) - { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); - - var offset = targetFramework switch - { - "net10.0" => 0, - "net9.0" => 1, - "net8.0" => 2, - _ => 0 // Default fallback - }; - - return basePort + offset; - } - - private readonly Task _serverTask; - private readonly CancellationTokenSource _serverCts; - - public string ServerUrl { get; } - - private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts) - { - ServerUrl = serverUrl; - _serverTask = serverTask; - _serverCts = serverCts; - } - - public static async Task StartAsync(CancellationToken cancellationToken, int basePort = 3011) - { - var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}"; - var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - // "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle - // (see ConformanceServer.Program), without mutating process-wide environment state. - var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token)); - - // Wait for the server to be ready (retry for up to 30 seconds). - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; - - while (stopwatch.Elapsed < timeout) - { - try - { - await httpClient.GetAsync($"{serverUrl}/health", cancellationToken); - return new StatelessConformanceServer(serverUrl, serverTask, serverCts); - } - catch (HttpRequestException) - { - // Connection refused means the server is not ready yet. - } - catch (TaskCanceledException) - { - // Timeout means the server might be processing; give it more time. - } - - await Task.Delay(500, cancellationToken); - } - - serverCts.Cancel(); - serverCts.Dispose(); - throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period"); - } - - public async ValueTask DisposeAsync() - { - _serverCts.Cancel(); - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch - { - // Ignore exceptions during shutdown. - } - _serverCts.Dispose(); - } -} - /// /// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, /// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK @@ -106,12 +9,13 @@ public async ValueTask DisposeAsync() /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. -/// It is gated on the installed conformance -/// package version (>= 0.2.0). The stateless server is -/// started only after the gates pass, so a skipped run binds no port. +/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle, +/// so it runs against the shared server's stateless endpoint +/// (). It is gated on the installed +/// conformance package version (>= 0.2.0). /// -public class CachingConformanceTests(ITestOutputHelper output) +[Collection(nameof(ConformanceServerCollection))] +public class CachingConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output) { [Fact] public async Task RunCachingConformanceTest() @@ -121,13 +25,11 @@ public async Task RunCachingConformanceTest() !NodeHelpers.HasCachingScenario(), "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); - await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); - // The caching scenario only exists in the 2026-07-28 protocol revision, so pin the spec version // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a // conflicting duplicate --spec-version flag). var result = await NodeHelpers.RunServerConformanceAsync( - $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", + $"server --url {fixture.StatelessServerUrl} --scenario caching --spec-version 2026-07-28", line => { try { output.WriteLine(line); } catch { } }, appendProtocolVersionFromEnv: false, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d83..dd38df9bc 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -69,7 +69,7 @@ public async Task RunConformanceTest(string scenario) // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, // failing every positive check. Re-enable once a conformant conformance package ships (#1655). - // [InlineData("http-custom-headers")] + //[InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs new file mode 100644 index 000000000..21fb98c2e --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP +/// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at +/// , "/stateless") on one port. A single long-lived server avoids +/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and +/// centralizes the port-binding logic that the stateful and stateless conformance tests previously +/// duplicated. Shared by and +/// via . +/// +public sealed class ConformanceServerFixture : IAsyncLifetime +{ + // Use different ports for each target framework to allow parallel execution across the + // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003. + private static int GetPortForTargetFramework() + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + return targetFramework switch + { + "net10.0" => 3001, + "net9.0" => 3002, + "net8.0" => 3003, + _ => 3001 // Default fallback + }; + } + + private Task? _serverTask; + private CancellationTokenSource? _serverCts; + + /// Base URL of the stateful MCP endpoint (mapped at "/"). + public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; + + /// + /// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28 + /// scenarios (caching, MRTR, SEP-2243) that negotiate the stateless lifecycle. + /// + public string StatelessServerUrl => $"{ServerUrl}/stateless"; + + public async ValueTask InitializeAsync() + { + _serverCts = new CancellationTokenSource(); + _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( + ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + + // Wait for server to be ready (retry for up to 30 seconds) + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + + while (stopwatch.Elapsed < timeout) + { + try + { + await httpClient.GetAsync($"{ServerUrl}/health"); + return; + } + catch (HttpRequestException) + { + // Connection refused means server not ready yet + } + catch (TaskCanceledException) + { + // Timeout means server might be processing, give it more time + } + + await Task.Delay(500); + } + + throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + if (_serverCts != null) + { + _serverCts.Cancel(); + if (_serverTask != null) + { + try + { + await _serverTask.WaitAsync(TestConstants.DefaultTimeout); + } + catch + { + // Ignore exceptions during shutdown + } + } + _serverCts.Dispose(); + } + } +} + +/// +/// xUnit collection that shares one across the conformance +/// test classes so they run against a single server instance (and a single bound port). +/// +[CollectionDefinition(nameof(ConformanceServerCollection))] +public sealed class ConformanceServerCollection : ICollectionFixture; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 82fb9c020..ea82a6c01 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,133 +1,20 @@ -using System.Diagnostics; using System.Runtime.InteropServices; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; /// -/// Shared fixture that starts a single ConformanceServer instance for all tests in -/// . This avoids TCP port TIME_WAIT conflicts -/// that occur when each test starts and stops its own server on the same port. -/// -public class ConformanceServerFixture : IAsyncLifetime -{ - // Use different ports for each target framework to allow parallel execution - // net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003 - private static int GetPortForTargetFramework() - { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); - - return targetFramework switch - { - "net10.0" => 3001, - "net9.0" => 3002, - "net8.0" => 3003, - _ => 3001 // Default fallback - }; - } - - private Task? _serverTask; - private CancellationTokenSource? _serverCts; - - public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; - - public async ValueTask InitializeAsync() - { - _serverCts = new CancellationTokenSource(); - // Explicitly pass "--stateless false" so this stateful fixture is immune to a globally - // set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins). - _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token)); - - // Wait for server to be ready (retry for up to 30 seconds) - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; - - while (stopwatch.Elapsed < timeout) - { - try - { - await httpClient.GetAsync($"{ServerUrl}/health"); - return; - } - catch (HttpRequestException) - { - // Connection refused means server not ready yet - } - catch (TaskCanceledException) - { - // Timeout means server might be processing, give it more time - } - - await Task.Delay(500); - } - - throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); - } - - public async ValueTask DisposeAsync() - { - if (_serverCts != null) - { - _serverCts.Cancel(); - if (_serverTask != null) - { - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch - { - // Ignore exceptions during shutdown - } - } - _serverCts.Dispose(); - } - } -} - -/// -/// Shared fixture that starts a single stateless ConformanceServer for the -/// SEP-2322 MRTR scenarios in . Those scenarios negotiate the -/// 2026-07-28 revision, which is served only on a stateless server, so they -/// cannot reuse the stateful . Reusing one server across all -/// the MRTR theory rows avoids the TCP TIME_WAIT conflicts that per-test restarts on a single port -/// cause on Windows. Uses a dedicated port range (303x) so it runs in parallel with the stateful -/// fixture (300x), the caching server (301x), and the SEP-2243 servers (302x) without colliding. -/// -public sealed class StatelessMrtrConformanceServerFixture : IAsyncLifetime -{ - private StatelessConformanceServer? _server; - - public string ServerUrl => _server?.ServerUrl - ?? throw new InvalidOperationException("The stateless conformance server has not been started."); - - public async ValueTask InitializeAsync() - { - _server = await StatelessConformanceServer.StartAsync(CancellationToken.None, basePort: 3031); - } - - public async ValueTask DisposeAsync() - { - if (_server is not null) - { - await _server.DisposeAsync(); - } - } -} - -/// -/// Runs the official MCP conformance tests against the ConformanceServer. -/// Uses a shared so the server is started once -/// and reused across all tests, avoiding TCP port conflicts on Windows. +/// Runs the official MCP conformance tests against the ConformanceServer. Uses a shared +/// so the server is started once and reused across all +/// tests, avoiding TCP port conflicts on Windows. Stateful scenarios use the server's "/" endpoint +/// (); 2026-07-28 scenarios that negotiate the +/// stateless lifecycle use its "/stateless" endpoint +/// (). /// +[Collection(nameof(ConformanceServerCollection))] public class ServerConformanceTests( ConformanceServerFixture fixture, - StatelessMrtrConformanceServerFixture statelessFixture, ITestOutputHelper output) - : IClassFixture, IClassFixture { [Fact] public async Task RunConformanceTests() @@ -176,15 +63,11 @@ public async Task RunConformanceTest_HttpHeaderValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless - // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call - // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with - // the stateful class fixture (300x) or the caching stateless server (301x). - await using var server = await StatelessConformanceServer.StartAsync( - TestContext.Current.CancellationToken, basePort: 3021); - + // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless lifecycle, + // so it runs against the shared server's stateless endpoint (a stateful server rejects the + // un-initialized list/call requests with JSON-RPC -32000). var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -198,11 +81,8 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - await using var server = await StatelessConformanceServer.StartAsync( - TestContext.Current.CancellationToken, basePort: 3024); - var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -215,8 +95,8 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() // "input-required-result-*"; the wire-level tool names now match the new convention). // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28, // so the csharp-sdk emits InputRequiredResult on the wire. Because the 2026-07-28 revision is - // served only on a stateless server, the scenarios run against a dedicated stateless server - // (StatelessMrtrConformanceServerFixture); a stateful server refuses these requests. + // served only on a stateless server, the scenarios run against the shared server's stateless + // endpoint (ConformanceServerFixture.StatelessServerUrl); a stateful server refuses these requests. // These tests skip until the installed conformance package ships SEP-2322 scenarios // (see ). // @@ -248,7 +128,7 @@ public async Task RunMrtrConformanceTest(string scenario) Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); var result = await RunStatelessConformanceTestAsync( - $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario {scenario} --spec-version 2026-07-28"); Assert.True(result.Success, $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index b1de1aa98..037751430 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Collections.Concurrent; +using System.Diagnostics; using System.Text.Json; namespace ModelContextProtocol.ConformanceServer; @@ -25,23 +26,34 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // because .NET does not have a built-in concurrent HashSet ConcurrentDictionary> subscriptions = new(); - // Allow running the server in the SEP-2575 stateless lifecycle, which the 2026-07-28 - // protocol's "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" - // command-line switch (read via configuration) takes precedence so an in-process test - // fixture can opt in or out per-instance deterministically; when it is not supplied, - // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. - // The default (no switch, no env var) remains the stateful server that serves the - // active conformance suite unchanged. - var statelessConfig = builder.Configuration["stateless"]; - var stateless = statelessConfig is not null - ? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase) - : string.Equals( - Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"), - "true", - StringComparison.OrdinalIgnoreCase); - - builder.Services.AddDistributedMemoryCache(); - builder.Services + // Configure the default, stateful MCP server (served at "/"). + ConfigureConformanceMcpServer(builder.Services, subscriptions, stateless: false); + + var app = builder.Build(); + + // Also expose a stateless MCP server at "/stateless" so a single conformance server can + // serve both the legacy stateful lifecycle (at "/") and the SEP-2575 stateless lifecycle + // (at "/stateless", which the 2026-07-28 "caching" (SEP-2549) and MRTR (SEP-2322) + // scenarios require) from one Kestrel port. + HandleStatelessMcp(app, subscriptions); + + app.MapMcp(); + + app.MapGet("/health", () => "Healthy"); + + await app.RunAsync(cancellationToken); + } + + // Registers the conformance MCP server (tools, prompts, resources, filters, and handlers) + // into the given service collection. Shared by the stateful ("/") and stateless ("/stateless") + // servers so both expose identical behavior and differ only in their lifecycle. + private static void ConfigureConformanceMcpServer( + IServiceCollection services, + ConcurrentDictionary> subscriptions, + bool stateless) + { + services.AddDistributedMemoryCache(); + services .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() @@ -157,14 +169,32 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide return new EmptyResult(); }); + } - var app = builder.Build(); - - app.MapMcp(); - - app.MapGet("/health", () => "Healthy"); - - await app.RunAsync(cancellationToken); + // Maps a second MCP server, configured for the stateless lifecycle, at "/stateless". It is + // built in its own ServiceCollection so its DI (and HttpServerTransportOptions) stays isolated + // from the stateful server registered on the main host. Adapted from + // ModelContextProtocol.TestSseServer.Program.HandleStatelessMcp. + private static void HandleStatelessMcp( + WebApplication app, + ConcurrentDictionary> subscriptions) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddRoutingCore(); + + ConfigureConformanceMcpServer(services, subscriptions, stateless: true); + + var statelessApp = new ApplicationBuilder(services.BuildServiceProvider()); + statelessApp.UseRouting(); + statelessApp.UseEndpoints(endpoints => endpoints.MapMcp("/stateless")); + + // Terminal middleware that serves "/stateless" requests the main host's routing did not + // match. Registered before app.MapMcp() so the stateful endpoints still win for "/". + app.Run(statelessApp.Build()); } public static async Task Main(string[] args) From 3a44274fb842c0a136bf47bd97ba3cd97f58836a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 2 Jul 2026 14:45:32 -0700 Subject: [PATCH 2/2] Register resource-subscription handlers only on the stateful server Addresses Tarek's review nit on #1672. The stateless server at "/stateless" shared the stateful server's subscriptions dictionary, which he flagged as a smell. The deeper issue: registering WithSubscribeToResourcesHandler / WithUnsubscribeFromResourcesHandler unconditionally set Capabilities.Resources.Subscribe = true, so the stateless server advertised resources.subscribe in its initialize result and then rejected every resources/subscribe call with -32603 (InternalError) via the null-SessionId guard -- the "server bug" error code for a capability it deliberately can't honor. Resource subscriptions are meaningless in the stateless lifecycle anyway: there is no stable SessionId to key the subscription table and no persistent SSE stream to deliver notifications/resources/updated. So gate the two handlers behind `if (!stateless)`. The stateless server no longer advertises resources.subscribe (an actual subscribe now gets the SDK's standard capability rejection), and the subscriptions dictionary is scoped to the stateful branch that is its only user -- answering "does the stateless server need a dictionary?" with a plain no. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 037751430..22e22275d 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -21,13 +21,8 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide builder.Logging.AddProvider(loggerProvider); } - // Dictionary of session IDs to a set of resource URIs they are subscribed to - // The value is a ConcurrentDictionary used as a thread-safe HashSet - // because .NET does not have a built-in concurrent HashSet - ConcurrentDictionary> subscriptions = new(); - // Configure the default, stateful MCP server (served at "/"). - ConfigureConformanceMcpServer(builder.Services, subscriptions, stateless: false); + ConfigureConformanceMcpServer(builder.Services, stateless: false); var app = builder.Build(); @@ -35,7 +30,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // serve both the legacy stateful lifecycle (at "/") and the SEP-2575 stateless lifecycle // (at "/stateless", which the 2026-07-28 "caching" (SEP-2549) and MRTR (SEP-2322) // scenarios require) from one Kestrel port. - HandleStatelessMcp(app, subscriptions); + HandleStatelessMcp(app); app.MapMcp(); @@ -46,14 +41,14 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // Registers the conformance MCP server (tools, prompts, resources, filters, and handlers) // into the given service collection. Shared by the stateful ("/") and stateless ("/stateless") - // servers so both expose identical behavior and differ only in their lifecycle. + // servers, which expose identical behavior except that only the stateful server registers the + // resource-subscription handlers (see below). private static void ConfigureConformanceMcpServer( IServiceCollection services, - ConcurrentDictionary> subscriptions, bool stateless) { services.AddDistributedMemoryCache(); - services + var mcpServerBuilder = services .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() @@ -115,33 +110,6 @@ private static void ConfigureConformanceMcpServer( .WithPrompts() .WithPrompts() .WithResources() - .WithSubscribeToResourcesHandler(async (ctx, ct) => - { - if (ctx.Server.SessionId == null) - { - throw new McpException("Cannot add subscription for server with null SessionId"); - } - if (ctx.Params.Uri is { } uri) - { - var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new()); - sessionSubscriptions.TryAdd(uri, 0); - } - - return new EmptyResult(); - }) - .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => - { - if (ctx.Server.SessionId == null) - { - throw new McpException("Cannot remove subscription for server with null SessionId"); - } - if (ctx.Params.Uri is { } uri) - { - subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); - } - - return new EmptyResult(); - }) .WithCompleteHandler(async (ctx, ct) => { // Basic completion support - returns empty array for conformance @@ -169,15 +137,54 @@ private static void ConfigureConformanceMcpServer( return new EmptyResult(); }); + + // Resource subscriptions require a stable SessionId to key the subscription table and a + // persistent SSE stream to deliver notifications/resources/updated, neither of which + // exists in the stateless lifecycle. Only the stateful server registers these handlers, + // so only it advertises the resources.subscribe capability. + if (!stateless) + { + // Dictionary of session IDs to a set of resource URIs they are subscribed to. The + // value is a ConcurrentDictionary used as a thread-safe HashSet because .NET does not + // have a built-in concurrent HashSet. + ConcurrentDictionary> subscriptions = new(); + + mcpServerBuilder + .WithSubscribeToResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot add subscription for server with null SessionId"); + } + if (ctx.Params.Uri is { } uri) + { + var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new()); + sessionSubscriptions.TryAdd(uri, 0); + } + + return new EmptyResult(); + }) + .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + if (ctx.Params.Uri is { } uri) + { + subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); + } + + return new EmptyResult(); + }); + } } // Maps a second MCP server, configured for the stateless lifecycle, at "/stateless". It is // built in its own ServiceCollection so its DI (and HttpServerTransportOptions) stays isolated // from the stateful server registered on the main host. Adapted from // ModelContextProtocol.TestSseServer.Program.HandleStatelessMcp. - private static void HandleStatelessMcp( - WebApplication app, - ConcurrentDictionary> subscriptions) + private static void HandleStatelessMcp(WebApplication app) { var services = new ServiceCollection(); services.AddLogging(); @@ -186,7 +193,7 @@ private static void HandleStatelessMcp( services.AddSingleton(app.Services.GetRequiredService()); services.AddRoutingCore(); - ConfigureConformanceMcpServer(services, subscriptions, stateless: true); + ConfigureConformanceMcpServer(services, stateless: true); var statelessApp = new ApplicationBuilder(services.BuildServiceProvider()); statelessApp.UseRouting();