Run stateful and stateless conformance endpoints in a single server process (#1672)

This commit is contained in:
Stephen Halter 2026-07-14 09:37:30 -07:00 • committed by GitHub
commit 1f9b8a3955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 216 additions and 293 deletions

View file

@ -1,104 +1,7 @@
using System.Diagnostics;
using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests; namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// 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 <see cref="DisposeAsync"/>. Uses a
/// distinct port range from the stateful <c>ConformanceServerFixture</c> (3001/3002/3003) so
/// the two can run in parallel without TCP conflicts.
/// </summary>
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<StatelessConformanceServer> 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();
}
}
/// <summary> /// <summary>
/// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, /// 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 /// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK
@ -106,12 +9,13 @@ internal sealed class StatelessConformanceServer : IAsyncDisposable
/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. /// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle,
/// It is gated on the installed conformance /// so it runs against the shared server's stateless endpoint
/// package's scenario list. The stateless server is /// (<see cref="ConformanceServerFixture.StatelessServerUrl"/>). It is gated on the installed
/// started only after the gates pass, so a skipped run binds no port. /// conformance package's scenario list.
/// </remarks> /// </remarks>
public class CachingConformanceTests(ITestOutputHelper output) [Collection(nameof(ConformanceServerCollection))]
public class CachingConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output)
{ {
[Fact] [Fact]
public async Task RunCachingConformanceTest() public async Task RunCachingConformanceTest()
@ -121,13 +25,11 @@ public class CachingConformanceTests(ITestOutputHelper output)
!NodeHelpers.HasCachingScenario(), !NodeHelpers.HasCachingScenario(),
"SEP-2549 caching conformance scenario is not available in the installed conformance package."); "SEP-2549 caching conformance scenario is not available in the installed conformance package.");
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 // 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 // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a
// conflicting duplicate --spec-version flag). // conflicting duplicate --spec-version flag).
var result = await NodeHelpers.RunServerConformanceAsync( 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 { } }, line => { try { output.WriteLine(line); } catch { } },
appendProtocolVersionFromEnv: false, appendProtocolVersionFromEnv: false,
cancellationToken: TestContext.Current.CancellationToken); cancellationToken: TestContext.Current.CancellationToken);

View file

@ -0,0 +1,104 @@
using System.Diagnostics;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP
/// lifecycle (at <see cref="ServerUrl"/>, "/") and the SEP-2575 stateless lifecycle (at
/// <see cref="StatelessServerUrl"/>, "/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 <see cref="ServerConformanceTests"/> and <see cref="CachingConformanceTests"/>
/// via <see cref="ConformanceServerCollection"/>.
/// </summary>
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;
/// <summary>Base URL of the stateful MCP endpoint (mapped at "/").</summary>
public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
/// <summary>
/// xUnit collection that shares one <see cref="ConformanceServerFixture"/> across the conformance
/// test classes so they run against a single server instance (and a single bound port).
/// </summary>
[CollectionDefinition(nameof(ConformanceServerCollection))]
public sealed class ConformanceServerCollection : ICollectionFixture<ConformanceServerFixture>;

View file

@ -1,133 +1,20 @@
using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests; namespace ModelContextProtocol.ConformanceTests;
/// <summary> /// <summary>
/// Shared fixture that starts a single ConformanceServer instance for all tests in /// Runs the official MCP conformance tests against the ConformanceServer. Uses a shared
/// <see cref="ServerConformanceTests"/>. This avoids TCP port TIME_WAIT conflicts /// <see cref="ConformanceServerFixture"/> so the server is started once and reused across all
/// that occur when each test starts and stops its own server on the same port. /// tests, avoiding TCP port conflicts on Windows. Stateful scenarios use the server's "/" endpoint
/// </summary> /// (<see cref="ConformanceServerFixture.ServerUrl"/>); 2026-07-28 scenarios that negotiate the
public class ConformanceServerFixture : IAsyncLifetime /// stateless lifecycle use its "/stateless" endpoint
{ /// (<see cref="ConformanceServerFixture.StatelessServerUrl"/>).
// 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();
}
}
}
/// <summary>
/// Shared fixture that starts a single stateless ConformanceServer for the
/// SEP-2322 MRTR scenarios in <see cref="ServerConformanceTests"/>. Those scenarios negotiate the
/// 2026-07-28 revision, which is served only on a stateless server, so they
/// cannot reuse the stateful <see cref="ConformanceServerFixture"/>. 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.
/// </summary>
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();
}
}
}
/// <summary>
/// Runs the official MCP conformance tests against the ConformanceServer.
/// Uses a shared <see cref="ConformanceServerFixture"/> so the server is started once
/// and reused across all tests, avoiding TCP port conflicts on Windows.
/// </summary> /// </summary>
[Collection(nameof(ConformanceServerCollection))]
public class ServerConformanceTests( public class ServerConformanceTests(
ConformanceServerFixture fixture, ConformanceServerFixture fixture,
StatelessMrtrConformanceServerFixture statelessFixture,
ITestOutputHelper output) ITestOutputHelper output)
: IClassFixture<ConformanceServerFixture>, IClassFixture<StatelessMrtrConformanceServerFixture>
{ {
[Fact] [Fact]
public async Task RunConformanceTests() public async Task RunConformanceTests()
@ -176,15 +63,11 @@ public class ServerConformanceTests(
!NodeHelpers.HasSep2243Scenarios(), !NodeHelpers.HasSep2243Scenarios(),
"SEP-2243 conformance scenarios are not available in the installed conformance package."); "SEP-2243 conformance scenarios are not available in the installed conformance package.");
// SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless lifecycle,
// lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call // so it runs against the shared server's stateless endpoint (a stateful server rejects the
// requests with JSON-RPC -32000). Use a dedicated port range so it never collides with // un-initialized list/call requests with JSON-RPC -32000).
// the stateful class fixture (300x) or the caching stateless server (301x).
await using var server = await StatelessConformanceServer.StartAsync(
TestContext.Current.CancellationToken, basePort: 3021);
var result = await RunStatelessConformanceTestAsync( 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, Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
@ -198,11 +81,8 @@ public class ServerConformanceTests(
!NodeHelpers.HasSep2243Scenarios(), !NodeHelpers.HasSep2243Scenarios(),
"SEP-2243 conformance scenarios are not available in the installed conformance package."); "SEP-2243 conformance scenarios are not available in the installed conformance package.");
await using var server = await StatelessConformanceServer.StartAsync(
TestContext.Current.CancellationToken, basePort: 3024);
var result = await RunStatelessConformanceTestAsync( 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, Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
@ -215,8 +95,8 @@ public class ServerConformanceTests(
// "input-required-result-*"; the wire-level tool names now match the new convention). // "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, // 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 // 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 // served only on a stateless server, the scenarios run against the shared server's stateless
// (StatelessMrtrConformanceServerFixture); a stateful server refuses these requests. // endpoint (ConformanceServerFixture.StatelessServerUrl); a stateful server refuses these requests.
// These tests skip until the installed conformance package ships SEP-2322 scenarios // These tests skip until the installed conformance package ships SEP-2322 scenarios
// (see <see cref="NodeHelpers.HasMrtrScenarios"/>). // (see <see cref="NodeHelpers.HasMrtrScenarios"/>).
// //
@ -248,7 +128,7 @@ public class ServerConformanceTests(
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package."); Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package.");
var result = await RunStatelessConformanceTestAsync( 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, Assert.True(result.Success,
$"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");

View file

@ -4,6 +4,7 @@ using ConformanceServer.Tools;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
namespace ModelContextProtocol.ConformanceServer; namespace ModelContextProtocol.ConformanceServer;
@ -20,28 +21,34 @@ public class Program
builder.Logging.AddProvider(loggerProvider); builder.Logging.AddProvider(loggerProvider);
} }
// Dictionary of session IDs to a set of resource URIs they are subscribed to // Configure the default, stateful MCP server (served at "/").
// The value is a ConcurrentDictionary used as a thread-safe HashSet ConfigureConformanceMcpServer(builder.Services, stateless: false);
// because .NET does not have a built-in concurrent HashSet
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions = new();
// Allow running the server in the SEP-2575 stateless lifecycle, which the 2026-07-28 var app = builder.Build();
// 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(); // Also expose a stateless MCP server at "/stateless" so a single conformance server can
builder.Services // 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);
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, which expose identical behavior except that only the stateful server registers the
// resource-subscription handlers (see below).
private static void ConfigureConformanceMcpServer(
IServiceCollection services,
bool stateless)
{
services.AddDistributedMemoryCache();
var mcpServerBuilder = services
.AddMcpServer() .AddMcpServer()
.WithHttpTransport(options => options.Stateless = stateless) .WithHttpTransport(options => options.Stateless = stateless)
.WithDistributedCacheEventStreamStore() .WithDistributedCacheEventStreamStore()
@ -103,33 +110,6 @@ public class Program
.WithPrompts<ConformancePrompts>() .WithPrompts<ConformancePrompts>()
.WithPrompts<IncompleteResultPrompts>() .WithPrompts<IncompleteResultPrompts>()
.WithResources<ConformanceResources>() .WithResources<ConformanceResources>()
.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) => .WithCompleteHandler(async (ctx, ct) =>
{ {
// Basic completion support - returns empty array for conformance // Basic completion support - returns empty array for conformance
@ -158,13 +138,70 @@ public class Program
return new EmptyResult(); return new EmptyResult();
}); });
var app = builder.Build(); // 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<string, ConcurrentDictionary<string, byte>> subscriptions = new();
app.MapMcp(); 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);
}
app.MapGet("/health", () => "Healthy"); 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 _);
}
await app.RunAsync(cancellationToken); 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)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(app.Services.GetRequiredService<ILoggerFactory>());
services.AddSingleton(app.Services.GetRequiredService<IHostApplicationLifetime>());
services.AddSingleton(app.Services.GetRequiredService<DiagnosticListener>());
services.AddRoutingCore();
ConfigureConformanceMcpServer(services, 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) public static async Task Main(string[] args)