Run stateful and stateless conformance endpoints in a single server process (#1672)
This commit is contained in:
parent
374e76eecd
commit
1f9b8a3955
1 changed files with 216 additions and 293 deletions
|
|
@ -1,133 +1,20 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.ConformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that starts a single ConformanceServer instance for all tests in
|
||||
/// <see cref="ServerConformanceTests"/>. This avoids TCP port TIME_WAIT conflicts
|
||||
/// that occur when each test starts and stops its own server on the same port.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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. Stateful scenarios use the server's "/" endpoint
|
||||
/// (<see cref="ConformanceServerFixture.ServerUrl"/>); 2026-07-28 scenarios that negotiate the
|
||||
/// stateless lifecycle use its "/stateless" endpoint
|
||||
/// (<see cref="ConformanceServerFixture.StatelessServerUrl"/>).
|
||||
/// </summary>
|
||||
[Collection(nameof(ConformanceServerCollection))]
|
||||
public class ServerConformanceTests(
|
||||
ConformanceServerFixture fixture,
|
||||
StatelessMrtrConformanceServerFixture statelessFixture,
|
||||
ITestOutputHelper output)
|
||||
: IClassFixture<ConformanceServerFixture>, IClassFixture<StatelessMrtrConformanceServerFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunConformanceTests()
|
||||
|
|
@ -176,15 +63,11 @@ public class ServerConformanceTests(
|
|||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"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
|
||||
// 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 class ServerConformanceTests(
|
|||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"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(
|
||||
$"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 class ServerConformanceTests(
|
|||
// "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 <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.");
|
||||
|
||||
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}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue