Run stateful and stateless conformance endpoints in a single server process (#1672)
This commit is contained in:
parent
374e76eecd
commit
1f9b8a3955
4 changed files with 216 additions and 293 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue