Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results (#1623)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
dbb7a20176
commit
c021d0a4f8
3 changed files with 1193 additions and 147 deletions
|
|
@ -0,0 +1,139 @@
|
|||
using System.Diagnostics;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.ConformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft
|
||||
/// SEP-2549 "caching" conformance scenario 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>
|
||||
/// 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
|
||||
/// correctly emits the <c>ttlMs</c> and <c>cacheScope</c> caching hints on cacheable results
|
||||
/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle.
|
||||
/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when
|
||||
/// running against the currently-pinned package, so it activates automatically once a
|
||||
/// conformance package containing the caching scenario is installed (including a local private
|
||||
/// build installed via <c>npm install --no-save <path-to-conformance></c>). The stateless
|
||||
/// server is started only after the gates pass, so a skipped run binds no port.
|
||||
/// </remarks>
|
||||
public class CachingConformanceTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunCachingConformanceTest()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!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 draft spec, 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 DRAFT-2026-v1",
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
appendProtocolVersionFromEnv: false,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"SEP-2549 caching conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ public class ClientConformanceTests
|
|||
}
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[InlineData("http-standard-headers")]
|
||||
[InlineData("http-custom-headers")]
|
||||
[InlineData("http-invalid-tool-headers")]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
|
||||
namespace ModelContextProtocol.ConformanceTests;
|
||||
|
|
@ -37,8 +35,10 @@ public class ConformanceServerFixture : IAsyncLifetime
|
|||
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], cancellationToken: _serverCts.Token));
|
||||
["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token));
|
||||
|
||||
// Wait for server to be ready (retry for up to 30 seconds)
|
||||
var timeout = TimeSpan.FromSeconds(30);
|
||||
|
|
@ -139,9 +139,19 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
public async Task RunConformanceTest_HttpHeaderValidation()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
|
||||
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation");
|
||||
// SEP-2243 is a draft (DRAFT-2026-v1) 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);
|
||||
|
||||
var result = await RunStatelessConformanceTestAsync(
|
||||
$"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
|
|
@ -151,9 +161,15 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
public async Task RunConformanceTest_HttpCustomHeaderServerValidation()
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
|
||||
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation");
|
||||
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 DRAFT-2026-v1");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
|
|
@ -189,94 +205,20 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
|
||||
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();
|
||||
|
||||
var process = new Process { StartInfo = startInfo };
|
||||
|
||||
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||
// throwing on a background thread if events arrive after the test completes.
|
||||
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
return (
|
||||
Success: false,
|
||||
Output: outputBuilder.ToString(),
|
||||
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
|
||||
);
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
|
||||
var stdoutText = outputBuilder.ToString();
|
||||
var stderrText = errorBuilder.ToString();
|
||||
|
||||
// The Node.js conformance runner can crash during cleanup on Windows with a libuv
|
||||
// assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
|
||||
// code even though every conformance check passed. When that happens, fall back to
|
||||
// parsing the "Test Results:" summary in stdout to decide success.
|
||||
bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
|
||||
|
||||
return (
|
||||
Success: success,
|
||||
Output: stdoutText,
|
||||
Error: stderrText
|
||||
);
|
||||
return await NodeHelpers.RunServerConformanceAsync(
|
||||
arguments,
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the conformance runner output for a "Test Results:" line such as
|
||||
/// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
|
||||
/// and none failed.
|
||||
/// </summary>
|
||||
private static bool ConformanceOutputIndicatesSuccess(string output)
|
||||
// For draft scenarios that pin --spec-version explicitly, suppress the
|
||||
// MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended.
|
||||
private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments)
|
||||
{
|
||||
// Match lines like "Passed: 3/3, 0 failed, 0 warnings"
|
||||
var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int passed = int.Parse(match.Groups[1].Value);
|
||||
int total = int.Parse(match.Groups[2].Value);
|
||||
int failed = int.Parse(match.Groups[3].Value);
|
||||
|
||||
return passed == total && failed == 0 && total > 0;
|
||||
return await NodeHelpers.RunServerConformanceAsync(
|
||||
arguments,
|
||||
line => { try { output.WriteLine(line); } catch { } },
|
||||
appendProtocolVersionFromEnv: false,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue