Bring up to 0.1.13 conformance (#1254)

This commit is contained in:
Stephen Toub 2026-02-10 11:33:52 -05:00 • committed by GitHub
commit 27b5eb8031
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1249 additions and 78 deletions

View file

@ -24,17 +24,32 @@ public class ClientConformanceTests
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
[InlineData("initialize")]
[InlineData("tools_call")]
[InlineData("elicitation-sep1034-client-defaults")]
[InlineData("sse-retry")]
[InlineData("auth/metadata-default")]
[InlineData("auth/metadata-var1")]
[InlineData("auth/metadata-var2")]
[InlineData("auth/metadata-var3")]
[InlineData("auth/basic-cimd")]
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
[InlineData("auth/scope-from-www-authenticate")]
[InlineData("auth/scope-from-scopes-supported")]
[InlineData("auth/scope-omitted-when-undefined")]
[InlineData("auth/scope-step-up")]
[InlineData("auth/scope-retry-limit")]
[InlineData("auth/token-endpoint-auth-basic")]
[InlineData("auth/token-endpoint-auth-post")]
[InlineData("auth/token-endpoint-auth-none")]
[InlineData("auth/resource-mismatch")]
[InlineData("auth/pre-registration")]
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
// [InlineData("auth/client-credentials-jwt")]
// [InlineData("auth/client-credentials-basic")]
public async Task RunConformanceTest(string scenario)
{
// Run the conformance test suite
@ -88,7 +103,20 @@ public class ClientConformanceTests
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
return (
Success: false,
Output: outputBuilder.ToString(),
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
);
}
return (
Success: process.ExitCode == 0,

View file

@ -5,11 +5,11 @@ using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// Runs the official MCP conformance tests against the ConformanceServer.
/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
/// and reports the results.
/// 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 ServerConformanceTests : IAsyncLifetime
public class ConformanceServerFixture : IAsyncLifetime
{
// Use different ports for each target framework to allow parallel execution
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
@ -27,22 +27,16 @@ public class ServerConformanceTests : IAsyncLifetime
};
}
private readonly int _serverPort = GetPortForTargetFramework();
private readonly string _serverUrl;
private readonly ITestOutputHelper _output;
private Task? _serverTask;
private CancellationTokenSource? _serverCts;
public ServerConformanceTests(ITestOutputHelper output)
{
_output = output;
_serverUrl = $"http://localhost:{_serverPort}";
}
public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
public async ValueTask InitializeAsync()
{
// Start the ConformanceServer
StartConformanceServer();
_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);
@ -53,9 +47,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
try
{
// Try to connect to the health endpoint
await httpClient.GetAsync($"{_serverUrl}/health");
// Any response (even an error) means the server is up
await httpClient.GetAsync($"{ServerUrl}/health");
return;
}
catch (HttpRequestException)
@ -75,7 +67,6 @@ public class ServerConformanceTests : IAsyncLifetime
public async ValueTask DisposeAsync()
{
// Stop the server
if (_serverCts != null)
{
_serverCts.Cancel();
@ -93,30 +84,52 @@ public class ServerConformanceTests : IAsyncLifetime
_serverCts.Dispose();
}
}
}
/// <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>
public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output)
: IClassFixture<ConformanceServerFixture>
{
[Fact]
public async Task RunConformanceTests()
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
// Run the conformance test suite
var result = await RunConformanceTestsAsync();
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl}");
// Report the results
Assert.True(result.Success,
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
private void StartConformanceServer()
[Fact]
public async Task RunPendingConformanceTest_JsonSchema202012()
{
// Start the server in a background task
_serverCts = new CancellationTokenSource();
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario json-schema-2020-12");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync()
[Fact]
public async Task RunPendingConformanceTest_ServerSsePolling()
{
var startInfo = NodeHelpers.ConformanceTestStartInfo($"server --url {_serverUrl}");
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario server-sse-polling");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
{
var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments);
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
@ -127,7 +140,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
output.WriteLine(e.Data);
outputBuilder.AppendLine(e.Data);
}
};
@ -136,7 +149,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
output.WriteLine(e.Data);
errorBuilder.AppendLine(e.Data);
}
};
@ -145,7 +158,20 @@ public class ServerConformanceTests : IAsyncLifetime
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
return (
Success: false,
Output: outputBuilder.ToString(),
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
);
}
return (
Success: process.ExitCode == 0,

View file

@ -470,11 +470,23 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
// Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
// The background IdleTrackingBackgroundService prunes sessions asynchronously after
// the PeriodicTimer (5s interval) tick fires. We advance past the 2-hour idle timeout
// then poll until the session returns NotFound. Each HTTP POST also refreshes the
// session's LastActivityTicks via AcquireReferenceAsync, so we must re-advance time
// each iteration to ensure the session appears idle again for the next prune pass.
var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
HttpStatusCode statusCode;
do
{
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
await Task.Delay(100, TestContext.Current.CancellationToken);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
statusCode = response.StatusCode;
}
while (statusCode != HttpStatusCode.NotFound && DateTime.UtcNow < deadline);
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, statusCode);
}
[Fact]