Tighten draft protocol negotiation boundaries (#1692)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
a8d374b280
commit
fd38f51e27
35 changed files with 1755 additions and 589 deletions
|
|
@ -179,17 +179,22 @@ public static class NodeHelpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2243 conformance scenarios are available, by reading the
|
||||
/// <em>installed</em> conformance package version from node_modules.
|
||||
/// The http-standard-headers, http-custom-headers, http-invalid-tool-headers,
|
||||
/// http-header-validation, and http-custom-header-server-validation scenarios were
|
||||
/// introduced in conformance package 0.2.0. Reading the installed version (rather than
|
||||
/// the pinned version in package.json) means this also returns <see langword="true"/>
|
||||
/// when a newer private build has been installed locally via
|
||||
/// <c>npm install --no-save <path-to-conformance></c>.
|
||||
/// Checks whether the SEP-2243 conformance scenarios are available in the installed
|
||||
/// conformance package.
|
||||
/// </summary>
|
||||
public static bool HasSep2243Scenarios()
|
||||
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
|
||||
=> HasInstalledConformanceScenarios(
|
||||
"http-standard-headers",
|
||||
"http-invalid-tool-headers",
|
||||
"http-header-validation",
|
||||
"http-custom-header-server-validation");
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2575 request-metadata client conformance scenario is available
|
||||
/// in the installed conformance package.
|
||||
/// </summary>
|
||||
public static bool HasRequestMetadataScenario()
|
||||
=> HasInstalledConformanceScenario("request-metadata");
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the installed conformance package contains a spec-conformant
|
||||
|
|
@ -213,54 +218,45 @@ public static class NodeHelpers
|
|||
/// locally via <c>npm install --no-save <path-to-conformance></c>.
|
||||
/// </summary>
|
||||
public static bool HasCachingScenario()
|
||||
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
|
||||
=> HasInstalledConformanceScenario("caching");
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the conformance package installed in node_modules
|
||||
/// has a version greater than or equal to <paramref name="minimumVersion"/>.
|
||||
/// Checks whether all named conformance scenarios are present in the installed
|
||||
/// <c>@modelcontextprotocol/conformance</c> bundle. This is intentionally based on the
|
||||
/// installed scenario list rather than the package version so prerelease/private builds are
|
||||
/// gated by the scenarios they actually contain.
|
||||
/// </summary>
|
||||
private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion)
|
||||
{
|
||||
var version = GetInstalledConformanceVersion();
|
||||
return version is not null && version >= minimumVersion;
|
||||
}
|
||||
private static bool HasInstalledConformanceScenarios(params string[] scenarioNames)
|
||||
=> ReadInstalledConformanceBundle() is { } bundle
|
||||
&& scenarioNames.All(scenarioName => HasInstalledConformanceScenario(bundle, scenarioName));
|
||||
|
||||
/// <summary>
|
||||
/// Reads the version of the conformance package actually installed in node_modules,
|
||||
/// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be
|
||||
/// parsed as a <see cref="Version"/>. Returns <see langword="null"/> if it cannot be
|
||||
/// determined.
|
||||
/// </summary>
|
||||
private static Version? GetInstalledConformanceVersion()
|
||||
private static bool HasInstalledConformanceScenario(string scenarioName)
|
||||
=> ReadInstalledConformanceBundle() is { } bundle
|
||||
&& HasInstalledConformanceScenario(bundle, scenarioName);
|
||||
|
||||
private static bool HasInstalledConformanceScenario(string bundle, string scenarioName)
|
||||
=> bundle.Contains($"`{scenarioName}`", StringComparison.Ordinal) ||
|
||||
bundle.Contains($"\"{scenarioName}\"", StringComparison.Ordinal) ||
|
||||
bundle.Contains($"'{scenarioName}'", StringComparison.Ordinal);
|
||||
|
||||
private static string? ReadInstalledConformanceBundle()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var packageJsonPath = Path.Combine(
|
||||
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json");
|
||||
var bundlePath = Path.Combine(
|
||||
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js");
|
||||
|
||||
// This is a skip gate for version-conditional conformance scenarios, so it must stay
|
||||
// side-effect-free. If the conformance package isn't installed, report no version (the
|
||||
// This is a skip gate for scenario-conditional conformance tests, so it must stay
|
||||
// side-effect-free. If the conformance package isn't installed, report no bundle (the
|
||||
// scenario is simply gated off); the actual scenario run path restores npm dependencies
|
||||
// separately via ConformanceTestStartInfo.
|
||||
if (!File.Exists(packageJsonPath))
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
|
||||
if (json.RootElement.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.GetString() is { } versionStr)
|
||||
{
|
||||
// Strip any prerelease/build suffix so System.Version can parse it.
|
||||
var core = versionStr.Split('-', '+')[0];
|
||||
if (Version.TryParse(core, out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return File.ReadAllText(bundlePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -459,7 +455,7 @@ public static class NodeHelpers
|
|||
await process.WaitForExitAsync(cts.Token);
|
||||
#else
|
||||
// net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill
|
||||
// extension and surface a timeout the same way the modern path does.
|
||||
// extension and surface a timeout the same way the current target-framework path does.
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(5));
|
||||
if (!process.HasExited)
|
||||
{
|
||||
|
|
@ -520,16 +516,24 @@ public static class NodeHelpers
|
|||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult)
|
||||
/// conformance scenarios are available, by reading the <em>installed</em> conformance
|
||||
/// package version from node_modules. The <c>incomplete-result-*</c> scenarios were
|
||||
/// introduced in conformance package 0.2.0 (see
|
||||
/// https://github.com/modelcontextprotocol/conformance/pull/188).
|
||||
/// Reading the installed version (rather than the pinned version in package.json) means
|
||||
/// this also returns <see langword="true"/> when a newer private build has been installed
|
||||
/// locally via <c>npm install --no-save <path-to-conformance></c>.
|
||||
/// conformance scenarios are available in the installed conformance package.
|
||||
/// </summary>
|
||||
public static bool HasMrtrScenarios()
|
||||
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
|
||||
=> HasInstalledConformanceScenarios(
|
||||
"input-required-result-basic-elicitation",
|
||||
"input-required-result-basic-sampling",
|
||||
"input-required-result-basic-list-roots",
|
||||
"input-required-result-request-state",
|
||||
"input-required-result-multiple-input-requests",
|
||||
"input-required-result-multi-round",
|
||||
"input-required-result-missing-input-response",
|
||||
"input-required-result-non-tool-request",
|
||||
"input-required-result-result-type",
|
||||
"input-required-result-unsupported-methods",
|
||||
"input-required-result-tampered-state",
|
||||
"input-required-result-capability-check",
|
||||
"input-required-result-ignore-extra-params",
|
||||
"input-required-result-validate-input");
|
||||
|
||||
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ internal sealed class StatelessConformanceServer : IAsyncDisposable
|
|||
/// <remarks>
|
||||
/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle.
|
||||
/// It is gated on the installed conformance
|
||||
/// package version (>= 0.2.0). The stateless server is
|
||||
/// package's scenario list. The stateless server is
|
||||
/// started only after the gates pass, so a skipped run binds no port.
|
||||
/// </remarks>
|
||||
public class CachingConformanceTests(ITestOutputHelper output)
|
||||
|
|
@ -119,7 +119,7 @@ public class CachingConformanceTests(ITestOutputHelper output)
|
|||
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).");
|
||||
"SEP-2549 caching conformance scenario is not available in the installed conformance package.");
|
||||
|
||||
await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public class ClientConformanceTests
|
|||
// Public static property required for SkipUnless attribute
|
||||
public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
|
||||
public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios();
|
||||
public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario();
|
||||
public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario();
|
||||
|
||||
public ClientConformanceTests(ITestOutputHelper output)
|
||||
|
|
@ -45,7 +46,7 @@ public class ClientConformanceTests
|
|||
[InlineData("auth/resource-mismatch")]
|
||||
[InlineData("auth/pre-registration")]
|
||||
|
||||
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata).
|
||||
// Backcompat: 2025-03-26 OAuth flows (no per-request metadata, root-location metadata).
|
||||
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
|
||||
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
||||
|
||||
|
|
@ -63,8 +64,18 @@ public class ClientConformanceTests
|
|||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
// Per-request metadata (SEP-2575)
|
||||
[Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))]
|
||||
public async Task RunConformanceTest_RequestMetadata()
|
||||
{
|
||||
var result = await RunClientConformanceScenario("request-metadata");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[InlineData("http-standard-headers")]
|
||||
[InlineData("http-invalid-tool-headers")]
|
||||
public async Task RunConformanceTest_Sep2243(string scenario)
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsUnionIntegerCanonicalForm()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body
|
||||
// carries the decimal form 42.0. The server must treat the union type as integer and match.
|
||||
|
|
@ -135,7 +135,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsUnionIntegerOutsideSafeRange()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
var callJson = CallTool("union_test", """{"priority":9007199254740993}""");
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsExponentBodyMatchingDecimalHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100".
|
||||
var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -177,7 +177,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243: servers MUST accept extra whitespace around header values
|
||||
// and compare the trimmed value to the request body.
|
||||
|
|
@ -201,7 +201,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243: servers MUST accept extra whitespace around header values
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -224,7 +224,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Send a tools/call with an empty string param that has an x-mcp-header.
|
||||
// The header should be present with an empty value, matching the body's empty string.
|
||||
|
|
@ -248,7 +248,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Send a tools/call where the body has a non-empty value but the header is empty
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}""");
|
||||
|
|
@ -271,7 +271,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsBase64EncodedHeaderWithControlChars()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Encode a value with a newline control character using Base64
|
||||
var valueWithNewline = "line1\nline2";
|
||||
|
|
@ -297,7 +297,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
|
||||
// losing precision through a double conversion.
|
||||
|
|
@ -326,7 +326,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243 integer values MUST be within the JavaScript safe integer range.
|
||||
// A matching header and body that are both outside the range must still be rejected.
|
||||
|
|
@ -355,7 +355,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and
|
||||
// "4.2e1" are preserved in the body exactly as another SDK might serialize them.
|
||||
|
|
@ -382,7 +382,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// For an integer-typed parameter a non-whole numeric value is invalid and must be rejected
|
||||
// even when the header and body strings are byte-for-byte identical (it must not slip through
|
||||
|
|
@ -407,7 +407,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Header says "99" but body says priority:42 — must reject even with numeric comparison
|
||||
var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -427,17 +427,17 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_SkipsHeaderValidation_ForLegacyVersion()
|
||||
public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithLegacyVersionAsync();
|
||||
await InitializeWithInitializeHandshakeVersionAsync();
|
||||
|
||||
// With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
// With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched.
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
||||
// Send the WRONG header value. This should still succeed because the version is legacy.
|
||||
// Send the WRONG header value. This should still succeed because the version uses initialize.
|
||||
request.Headers.Add("MCP-Protocol-Version", "2025-11-25");
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "header_test");
|
||||
|
|
@ -451,7 +451,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsInvalidUtf8EncodedHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers
|
||||
// instead of properly base64-encoding non-ASCII values.
|
||||
|
|
@ -561,40 +561,39 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
[InlineData("2024-11-05", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected)
|
||||
public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version));
|
||||
Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private async Task InitializeWithJuly2026ProtocolVersionAsync()
|
||||
private async Task ProbeWithJuly2026ProtocolVersionAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
|
||||
request.Content = JsonContent(DiscoverRequestJuly2026Protocol);
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "initialize");
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a
|
||||
// mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28
|
||||
// so each one is handled independently.
|
||||
// Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request
|
||||
// metadata instead of initialize.
|
||||
}
|
||||
|
||||
private async Task InitializeWithLegacyVersionAsync()
|
||||
private async Task InitializeWithInitializeHandshakeVersionAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return
|
||||
// Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return
|
||||
// a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision.
|
||||
}
|
||||
|
||||
|
|
@ -602,22 +601,24 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
|
||||
private long _lastRequestId = 1;
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}")
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$$"""
|
||||
{"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}}
|
||||
""";
|
||||
var meta = includePerRequestMetadata
|
||||
? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}"
|
||||
: "";
|
||||
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" +
|
||||
toolName + "\",\"arguments\":" + arguments + meta + "}}";
|
||||
}
|
||||
|
||||
private static string InitializeRequest => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
|
||||
""";
|
||||
|
||||
private static string InitializeRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ using System.Text.Json.Serialization.Metadata;
|
|||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These
|
||||
/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize
|
||||
/// handshake over Streamable HTTP. These
|
||||
/// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's
|
||||
/// <c>simple-streamablehttp-stateless</c> returns a JSON-RPC error envelope in a <c>400</c> body
|
||||
/// on a 2026-07-28 probe; vanilla Go does the same on <c>POST /</c>) so the client's HTTP-fallback
|
||||
|
|
@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
/// <item>
|
||||
/// <see cref="StreamableHttpClientSessionTransport"/> only surfaced the three error codes
|
||||
/// introduced by the 2026-07-28 revision (<c>-32022</c>, <c>-32021</c>, <c>-32020</c>) as <see cref="McpProtocolException"/>;
|
||||
/// any other JSON-RPC error code in a <c>400</c> body (e.g. <c>-32600</c> from a legacy server
|
||||
/// any other JSON-RPC error code in a <c>400</c> body (e.g. <c>-32600</c> from an initialize-handshake server
|
||||
/// that doesn't understand the 2026-07-28 <c>_meta</c> envelope) threw <see cref="HttpRequestException"/>
|
||||
/// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger
|
||||
/// on ANY non-modern JSON-RPC error in a <c>400</c> body.
|
||||
/// on ANY non-SEP-2575 JSON-RPC error in a <c>400</c> body.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="AutoDetectingClientSessionTransport"/> treated any non-2xx HTTP response as a
|
||||
/// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked
|
||||
/// application-level errors (including the three modern codes) because the SSE GET would
|
||||
/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would
|
||||
/// either fail with "session id required" or succeed against a different endpoint and lose
|
||||
/// the actual signal.
|
||||
/// </item>
|
||||
|
|
@ -86,11 +87,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
/// <summary>
|
||||
/// Mimics Python's <c>simple-streamablehttp-stateless</c> on a 2026-07-28 probe: returns
|
||||
/// <c>400</c> + JSON-RPC <c>-32600</c> ("Bad Request: Unsupported protocol version") for the
|
||||
/// initial <c>server/discover</c>, then performs a normal legacy <c>initialize</c> handshake
|
||||
/// initial <c>server/discover</c>, then performs a normal <c>initialize</c> handshake
|
||||
/// when the client falls back.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError()
|
||||
public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
|
|
@ -107,7 +108,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
return;
|
||||
}
|
||||
|
||||
// 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with
|
||||
// 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with
|
||||
// a -32600 envelope (matches Python's wire shape verified in cross-SDK testing).
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
{
|
||||
|
|
@ -115,7 +116,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
return;
|
||||
}
|
||||
|
||||
// Legacy initialize: respond with the highest version the legacy server speaks.
|
||||
// Initialize handshake: respond with the highest version this server speaks.
|
||||
if (request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
var response = new JsonRpcResponse
|
||||
|
|
@ -123,9 +124,9 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2025-06-18",
|
||||
ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion,
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "legacy", Version = "1.0" },
|
||||
ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
|
|
@ -157,11 +158,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to a legacy server.
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
// Sanity: subsequent traffic still works post-fallback.
|
||||
var tools = await client.ListToolsAsync(cancellationToken: ct);
|
||||
|
|
@ -170,7 +171,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
|
||||
/// <summary>
|
||||
/// Mimics vanilla Go: returns <c>400</c> + JSON-RPC <c>-32022</c> with
|
||||
/// <c>data.supported[]</c> on a 2026-07-28 probe so the client retries legacy
|
||||
/// <c>data.supported[]</c> on a 2026-07-28 probe so the client retries
|
||||
/// <c>initialize</c> with one of the advertised versions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
|
@ -197,8 +198,8 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
// Use the typed payload type so the source-generated serializer can handle it.
|
||||
var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData
|
||||
{
|
||||
Supported = new List<string> { "2025-11-25" },
|
||||
Requested = "2026-07-28",
|
||||
Supported = new List<string> { McpProtocolVersions.November2025ProtocolVersion },
|
||||
Requested = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, GetJsonTypeInfo<UnsupportedProtocolVersionErrorData>());
|
||||
|
||||
var rpcError = new JsonRpcError
|
||||
|
|
@ -225,7 +226,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2025-11-25",
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
|
|
@ -244,16 +245,16 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to a legacy server.
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 400 with a JSON-RPC <c>-32020 HeaderMismatch</c> envelope must be surfaced to the
|
||||
/// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope.
|
||||
/// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback()
|
||||
|
|
@ -293,11 +294,133 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion,
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode);
|
||||
Assert.False(initializeReceived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
string? toolsListSessionId = null;
|
||||
|
||||
await StartServerAsync(async context =>
|
||||
{
|
||||
var message = await JsonSerializer.DeserializeAsync(
|
||||
context.Request.Body,
|
||||
GetJsonTypeInfo<JsonRpcMessage>(),
|
||||
ct);
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request)
|
||||
{
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" },
|
||||
TimeToLive = TimeSpan.Zero,
|
||||
CacheScope = CacheScope.Private,
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session";
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest)
|
||||
{
|
||||
toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString();
|
||||
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = toolsListRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
});
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: ct);
|
||||
|
||||
Assert.Null(client.SessionId);
|
||||
Assert.Equal("", toolsListSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
string? discoverSessionId = null;
|
||||
|
||||
await StartServerAsync(async context =>
|
||||
{
|
||||
var message = await JsonSerializer.DeserializeAsync(
|
||||
context.Request.Body,
|
||||
GetJsonTypeInfo<JsonRpcMessage>(),
|
||||
ct);
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request)
|
||||
{
|
||||
discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString();
|
||||
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" },
|
||||
TimeToLive = TimeSpan.Zero,
|
||||
CacheScope = CacheScope.Private,
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
});
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
KnownSessionId = "legacy-session",
|
||||
OwnsSession = false,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("", discoverSessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
|
||||
/// <summary>
|
||||
/// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server
|
||||
/// suppresses the <c>Mcp-Session-Id</c> header for those requests and returns structured
|
||||
/// does not issue <c>Mcp-Session-Id</c> for those requests and returns structured
|
||||
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> errors instead of plain 400s.
|
||||
/// </summary>
|
||||
public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
|
|
@ -52,12 +52,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
// On a stateless server, server/discover succeeds without creating a session.
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -70,15 +70,15 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567),
|
||||
// so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that
|
||||
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back
|
||||
// to the legacy initialize handshake.
|
||||
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back
|
||||
// to the initialize handshake.
|
||||
await StartAsync(stateless: false);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -93,22 +93,22 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
var dataElement = (JsonElement)rpcError.Error.Data!;
|
||||
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(errorData);
|
||||
Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested);
|
||||
// The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version.
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested);
|
||||
// The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version.
|
||||
Assert.NotEmpty(errorData.Supported);
|
||||
Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported);
|
||||
Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError()
|
||||
{
|
||||
await StartAsync();
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31");
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -128,23 +128,23 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Request_WithMcpSessionIdHeader_IsRejected()
|
||||
public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId()
|
||||
{
|
||||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
|
||||
// a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the
|
||||
// Stateless setting.
|
||||
await StartAsync();
|
||||
// a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs.
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains("Mcp-Session-Id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -152,11 +152,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -164,12 +165,13 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -177,11 +179,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -189,11 +192,16 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
|
||||
/// is <c>false</c>). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports
|
||||
/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with <c>-32022 UnsupportedProtocolVersion</c>.
|
||||
/// The client must then auto-downgrade to the legacy <c>initialize</c> handshake, obtain the stateful session
|
||||
/// The client must then auto-downgrade to the <c>initialize</c> handshake, obtain the stateful session
|
||||
/// the server author opted into, and continue to work, including a server→client elicitation round-trip
|
||||
/// resolved over the stateful session via the legacy backcompat resolver.
|
||||
/// resolved over the stateful session via the initialize-handshake backcompat resolver.
|
||||
/// </summary>
|
||||
public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
{
|
||||
|
|
@ -38,7 +38,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
private static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
|
||||
{
|
||||
// Server→client round-trip: only works when the session is stateful, which is exactly what
|
||||
// the legacy fallback re-establishes for the 2026-07-28-first client.
|
||||
// the initialize fallback re-establishes for the 2026-07-28-first client.
|
||||
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
|
||||
{
|
||||
Message = "What is your name?",
|
||||
|
|
@ -77,21 +77,21 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes
|
||||
// with server/discover before falling back to a legacy initialize handshake.
|
||||
// with server/discover before falling back to an initialize handshake.
|
||||
var clientOptions = new McpClientOptions();
|
||||
configureClient?.Invoke(clientOptions);
|
||||
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork()
|
||||
public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork()
|
||||
{
|
||||
await StartStatefulServerAsync();
|
||||
|
||||
await using var client = await ConnectDefaultClientAsync();
|
||||
|
||||
// The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy.
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
// The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize.
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("greet",
|
||||
new Dictionary<string, object?> { ["name"] = "Alice" },
|
||||
|
|
@ -118,7 +118,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
});
|
||||
});
|
||||
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("greet_via_elicit",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<ItemGroup>
|
||||
<Compile Include="..\Common\**\*.cs" />
|
||||
<Compile Include="..\..\src\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
<Compile Include="..\..\src\Common\McpProtocolVersions.cs" Link="McpProtocolVersions.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
// headers. No initialize handshake and no Mcp-Session-Id.
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
|
@ -261,7 +261,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream()
|
||||
{
|
||||
// Configure a server that does NOT pin 2026-07-28 so it can negotiate the current
|
||||
// protocol with a legacy client. The backcompat resolver path only runs when the
|
||||
// initialize-handshake protocol. The backcompat resolver path only runs when the
|
||||
// negotiated version is not 2026-07-28.
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
|
|
@ -302,7 +302,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
|
||||
// Initialize with the current legacy protocol so the server's backcompat resolver runs.
|
||||
// Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs.
|
||||
var initJson = """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}}
|
||||
""";
|
||||
|
|
@ -337,7 +337,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
// the response headers arrive instead of waiting for the SSE stream to close.
|
||||
var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null)
|
||||
{
|
||||
Content = JsonContent(CallTool("backcompat-roots-tool")),
|
||||
Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)),
|
||||
};
|
||||
callRequest.Content.Headers.Add("Mcp-Method", "tools/call");
|
||||
callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool");
|
||||
|
|
@ -456,16 +456,49 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
|
||||
private long _lastRequestId = 1;
|
||||
|
||||
private string Request(string method, string parameters = "{}")
|
||||
private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$"""
|
||||
{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}}
|
||||
""";
|
||||
var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject();
|
||||
if (includePerRequestMetadata)
|
||||
{
|
||||
AddJuly2026ProtocolMeta(paramsObj);
|
||||
}
|
||||
|
||||
var request = new JsonObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["id"] = id,
|
||||
["method"] = method,
|
||||
["params"] = paramsObj,
|
||||
};
|
||||
|
||||
return request.ToJsonString();
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}") =>
|
||||
private static void AddJuly2026ProtocolMeta(JsonObject paramsObj)
|
||||
{
|
||||
if (paramsObj["_meta"] is not JsonObject meta)
|
||||
{
|
||||
meta = [];
|
||||
paramsObj["_meta"] = meta;
|
||||
}
|
||||
|
||||
meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion;
|
||||
meta[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "MrtrTestClient",
|
||||
["version"] = "1.0",
|
||||
};
|
||||
|
||||
if (meta[MetaKeys.ClientCapabilities] is not JsonObject)
|
||||
{
|
||||
meta[MetaKeys.ClientCapabilities] = new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) =>
|
||||
Request("tools/call", $$"""
|
||||
{"name":"{{toolName}}","arguments":{{arguments}}}
|
||||
""");
|
||||
""", includePerRequestMetadata);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
|
||||
private WebApplication? _app;
|
||||
|
||||
private async Task StartAsync()
|
||||
private async Task StartAsync(string? protocolVersion = null)
|
||||
{
|
||||
Builder.Services
|
||||
.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" };
|
||||
options.ProtocolVersion = protocolVersion;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]);
|
||||
|
|
@ -82,7 +83,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
return JsonNode.Parse(body)!;
|
||||
}
|
||||
|
||||
private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) =>
|
||||
private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) =>
|
||||
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
|
||||
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
|
||||
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
|
||||
|
|
@ -97,7 +98,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
|
@ -119,14 +120,14 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported);
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
|
||||
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
|
||||
|
|
@ -135,6 +136,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion()
|
||||
{
|
||||
await StartAsync(McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022()
|
||||
{
|
||||
|
|
@ -151,7 +170,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload
|
||||
// listing the supported versions. The dual-era client uses this to switch versions without fallback.
|
||||
// listing the supported versions. The dual-path client uses this to switch versions without fallback.
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
|
||||
|
|
@ -160,7 +179,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.NotNull(data);
|
||||
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
|
||||
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported);
|
||||
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -171,17 +190,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
// The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a
|
||||
// different (still individually supported) version. Per SEP-2575 the server MUST reject the
|
||||
// disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body
|
||||
// checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a
|
||||
// legacy one and falling back to the initialize handshake.
|
||||
// checks) so a conformant client on this revision surfaces the error instead of mistaking the
|
||||
// per-request-metadata server for an initialize-handshake one and falling back to initialize.
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" +
|
||||
July2026ProtocolMetaFragment("2025-11-25") + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
|
|
@ -203,7 +223,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -214,11 +234,87 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyInitialize_StillSucceeds_OnDefaultServer()
|
||||
public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion()
|
||||
{
|
||||
await StartAsync(McpProtocolVersions.November2025ProtocolVersion);
|
||||
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
|
||||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue<string>());
|
||||
|
||||
var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}";
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", RequestMethods.Initialize);
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeHandshake_StillSucceeds_OnDefaultServer()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
|
@ -243,4 +339,3 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
|
|||
var body = july2026Protocol
|
||||
? """
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""".Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion)
|
||||
""".Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion)
|
||||
: """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}""";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||||
|
|
@ -125,7 +125,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
|
|||
|
||||
if (july2026Protocol)
|
||||
{
|
||||
request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "blockingTool");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ public class ServerConformanceTests(
|
|||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
"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
|
||||
|
|
@ -196,7 +196,7 @@ public class ServerConformanceTests(
|
|||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
"SEP-2243 conformance scenarios are not available in the installed conformance package.");
|
||||
|
||||
await using var server = await StatelessConformanceServer.StartAsync(
|
||||
TestContext.Current.CancellationToken, basePort: 3024);
|
||||
|
|
@ -245,7 +245,7 @@ public class ServerConformanceTests(
|
|||
public async Task RunMrtrConformanceTest(string scenario)
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package.");
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -951,12 +951,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server.
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
// Initialize with the 2026-07-28 protocol version to enable header validation
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
// Probe with the 2026-07-28 protocol version to enable header validation.
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request without Mcp-Method header — should be rejected
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
// Deliberately omit Mcp-Method header
|
||||
|
||||
|
|
@ -968,11 +968,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader()
|
||||
{
|
||||
await StartAsync(stateless: true);
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request but set Mcp-Method to wrong value
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method
|
||||
|
||||
|
|
@ -984,11 +984,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader()
|
||||
{
|
||||
await StartAsync(stateless: true);
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request with correct Mcp-Method and Mcp-Name headers
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
|
|
@ -998,12 +998,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyVersion_DoesNotRequireMcpMethodHeader()
|
||||
public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
// With the legacy version, Mcp-Method header is not required
|
||||
// With the initialize-handshake version, Mcp-Method header is not required.
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2025-03-26");
|
||||
|
|
@ -1013,25 +1013,25 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync()
|
||||
private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
|
||||
request.Content = JsonContent(DiscoverRequestJuly2026Protocol);
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "initialize");
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
var rpcResponse = await AssertSingleSseResponseAsync(response);
|
||||
AssertServerInfo(rpcResponse);
|
||||
AssertDiscoverServerInfo(rpcResponse);
|
||||
|
||||
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id.
|
||||
// Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently.
|
||||
// Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request
|
||||
// metadata instead of initialize.
|
||||
}
|
||||
|
||||
private static string InitializeRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
|
|
@ -1103,10 +1103,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
""";
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$"""
|
||||
{"name":"{{toolName}}","arguments":{{arguments}}}
|
||||
""");
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false)
|
||||
{
|
||||
var meta = includePerRequestMetadata
|
||||
? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}"
|
||||
: "";
|
||||
|
||||
return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}");
|
||||
}
|
||||
|
||||
private string CallToolWithProgressToken(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$$"""
|
||||
|
|
@ -1126,6 +1130,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
return initializeResult;
|
||||
}
|
||||
|
||||
private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var discoverResult = AssertType<DiscoverResult>(rpcResponse.Result);
|
||||
Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name);
|
||||
Assert.Equal("73", discoverResult.ServerInfo.Version);
|
||||
return discoverResult;
|
||||
}
|
||||
|
||||
private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var callToolResponse = AssertType<CallToolResult>(rpcResponse.Result);
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ McpClientOptions options = new()
|
|||
};
|
||||
|
||||
// The default client now prefers the 2026-07-28 protocol (probing with server/discover and
|
||||
// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios
|
||||
// specifically exercise the legacy initialize handshake and SSE resumability (removed in the
|
||||
// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios
|
||||
// specifically exercise the initialize handshake and SSE resumability (removed in the
|
||||
// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the
|
||||
// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the
|
||||
// server/discover probe plus the transparent legacy fallback.
|
||||
// server/discover probe plus the transparent initialize-handshake fallback.
|
||||
if (scenario is "initialize" or "sse-retry")
|
||||
{
|
||||
options.ProtocolVersion = "2025-11-25";
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ namespace ModelContextProtocol.Tests.Client;
|
|||
/// <summary>
|
||||
/// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567)
|
||||
/// on <see cref="McpClient"/>. A client that requests
|
||||
/// <see cref="McpHttpHeaders.July2026ProtocolVersion"/> calls <c>server/discover</c> rather than
|
||||
/// <see cref="McpProtocolVersions.July2026ProtocolVersion"/> calls <c>server/discover</c> rather than
|
||||
/// <c>initialize</c>.
|
||||
/// </summary>
|
||||
public class July2026ProtocolConnectionTests : ClientServerTestBase
|
||||
{
|
||||
private const string LatestStableVersion = "2025-11-25";
|
||||
private const string LatestStableVersion = McpProtocolVersions.November2025ProtocolVersion;
|
||||
|
||||
public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper, startServer: false)
|
||||
|
|
@ -34,42 +34,40 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
|
|||
{
|
||||
StartServer();
|
||||
|
||||
var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion };
|
||||
var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion };
|
||||
await using var client = await CreateMcpClientForServer(options);
|
||||
|
||||
Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
Assert.NotNull(client.ServerCapabilities);
|
||||
Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_RequestingLegacyVersion_NegotiatesLegacy()
|
||||
public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt()
|
||||
{
|
||||
StartServer();
|
||||
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CanCallServerDiscover()
|
||||
public async Task InitializeHandshakeClient_CannotCallServerDiscover()
|
||||
{
|
||||
// server/discover is registered unconditionally, so a legacy client can probe it
|
||||
// (e.g., to learn capabilities without doing a second initialize).
|
||||
// server/discover is registered unconditionally so the protocol boundary filter can return a structured
|
||||
// error, but initialize-handshake clients cannot use it after negotiating an older protocol version.
|
||||
StartServer();
|
||||
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
var response = await client.SendRequestAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
|
||||
TestContext.Current.CancellationToken);
|
||||
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
await client.SendRequestAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverResult);
|
||||
Assert.NotEmpty(discoverResult.SupportedVersions);
|
||||
Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions);
|
||||
Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name);
|
||||
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
|
||||
Assert.Contains(RequestMethods.ServerDiscover, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -85,6 +83,6 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase
|
|||
|
||||
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(discoverResult);
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions);
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ using System.Threading.Channels;
|
|||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in
|
||||
/// Regression tests for the fallback from the 2026-07-28 protocol revision to an initialize-handshake protocol in
|
||||
/// <see cref="McpClient"/>. With default options (<c>ProtocolVersion = null</c>) the client prefers
|
||||
/// 2026-07-28 but probes with <c>server/discover</c>, falls back to the legacy <c>initialize</c>
|
||||
/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy
|
||||
/// 2026-07-28 but probes with <c>server/discover</c>, falls back to the <c>initialize</c>
|
||||
/// handshake when the server only supports that path, and accepts whatever supported protocol version the
|
||||
/// server negotiates. Pinning <c>ProtocolVersion</c> to <c>2026-07-28</c> instead makes it the
|
||||
/// minimum too, so the client refuses to fall back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The originally shipped logic in <c>PerformLegacyInitializeAsync</c> compared the server's response
|
||||
/// against the requested version and threw when a legacy server downgraded to (say) <c>"2025-06-18"</c>,
|
||||
/// even though the legacy negotiation succeeded. These tests guard against that regression.
|
||||
/// The originally shipped initialize-handshake fallback logic compared the server's response
|
||||
/// against the requested version and threw when an initialize-handshake server downgraded to (say)
|
||||
/// <c>"2025-06-18"</c>, even though negotiation succeeded. These tests guard against that regression.
|
||||
/// </remarks>
|
||||
public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||
{
|
||||
|
|
@ -26,45 +26,66 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18");
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion);
|
||||
|
||||
// Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion);
|
||||
Assert.True(transport.InitializeReceived);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_OnInvalidParams_FallsBackTo_Initialize()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(
|
||||
serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion,
|
||||
probeErrorCode: (int)McpErrorCode.InvalidParams);
|
||||
|
||||
// Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.True(transport.InitializeReceived);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer()
|
||||
public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializeResponse()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18");
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(
|
||||
serverNegotiatedVersion: McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.IsType<McpException>(exception);
|
||||
Assert.True(transport.InitializeReceived);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion);
|
||||
Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
// Pinning the version makes it the minimum too, so the client refuses to fall back.
|
||||
ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion,
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
|
|
@ -73,17 +94,17 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch()
|
||||
public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactVersionMatch()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
// Server responds with a DIFFERENT version than the one the user pinned.
|
||||
await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26");
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.March2025ProtocolVersion);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = "2025-11-25",
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
|
|
@ -94,36 +115,50 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
[Fact]
|
||||
public async Task Client_OnHeaderMismatch_Surfaces_NoFallback()
|
||||
{
|
||||
// The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe).
|
||||
// Falling back to legacy initialize would just produce another malformed envelope.
|
||||
// The peer uses per-request metadata (returns the spec-defined -32020 HeaderMismatch on the probe).
|
||||
// Falling back to initialize would just produce another malformed envelope.
|
||||
// Verify the connect-time logic surfaces the error to the caller instead of falling back.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(
|
||||
serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion,
|
||||
probeErrorCode: (int)McpErrorCode.HeaderMismatch);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<McpException>(async () =>
|
||||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion,
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.False(transport.LegacyInitializeReceived);
|
||||
Assert.False(transport.InitializeReceived);
|
||||
Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_OnUnsupportedProtocolVersion_WithPerRequestMetadataVersion_RetriesDiscover()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new PerRequestMetadataRetryTestTransport();
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal(2, transport.ServerDiscoverRequests);
|
||||
Assert.False(transport.InitializeReceived);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout()
|
||||
{
|
||||
// Simulate a legacy server that silently drops the unknown server/discover method (it never
|
||||
// responds to the probe). The client must fall back to legacy initialize once the configured
|
||||
// Simulate an initialize-handshake server that silently drops the unknown server/discover method (it never
|
||||
// responds to the probe). The client must fall back to initialize once the configured
|
||||
// DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new LegacyServerTestTransport(
|
||||
serverNegotiatedVersion: "2025-11-25",
|
||||
await using var transport = new InitializeHandshakeServerTestTransport(
|
||||
serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion,
|
||||
silentDiscoverProbe: true);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
|
@ -136,8 +171,9 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
stopwatch.Stop();
|
||||
|
||||
Assert.True(transport.ServerDiscoverProbed);
|
||||
Assert.True(transport.LegacyInitializeReceived);
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.True(transport.InitializeReceived);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
// The fallback was driven by the short probe timeout, not the 60s InitializationTimeout.
|
||||
Assert.True(
|
||||
|
|
@ -171,23 +207,25 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory transport that simulates a legacy server: rejects
|
||||
/// Minimal in-memory transport that simulates an initialize-handshake server: rejects
|
||||
/// <c>server/discover</c> (with a configurable JSON-RPC error code, or by
|
||||
/// silently dropping the request) and responds to <c>initialize</c> with a
|
||||
/// configurable protocol version.
|
||||
/// </summary>
|
||||
private sealed class LegacyServerTestTransport(
|
||||
private sealed class InitializeHandshakeServerTestTransport(
|
||||
string serverNegotiatedVersion,
|
||||
int probeErrorCode = (int)McpErrorCode.MethodNotFound,
|
||||
bool silentDiscoverProbe = false) : IClientTransport
|
||||
{
|
||||
private readonly Channel<JsonRpcMessage> _incomingToClient = Channel.CreateUnbounded<JsonRpcMessage>();
|
||||
|
||||
public string Name => "legacy-server-test-transport";
|
||||
public string Name => "initialize-handshake-server-test-transport";
|
||||
|
||||
public bool ServerDiscoverProbed { get; private set; }
|
||||
|
||||
public bool LegacyInitializeReceived { get; private set; }
|
||||
public bool InitializeReceived { get; private set; }
|
||||
|
||||
public string? InitializeProtocolVersion { get; private set; }
|
||||
|
||||
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
@ -205,7 +243,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
ServerDiscoverProbed = true;
|
||||
if (silentDiscoverProbe)
|
||||
{
|
||||
// Model a legacy server that drops the unknown method without replying.
|
||||
// Model an initialize-handshake server that drops the unknown method without replying.
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +261,9 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
break;
|
||||
|
||||
case JsonRpcRequest { Method: RequestMethods.Initialize } initReq:
|
||||
LegacyInitializeReceived = true;
|
||||
InitializeReceived = true;
|
||||
var initializeRequest = JsonSerializer.Deserialize<InitializeRequestParams>(initReq.Params, McpJsonUtilities.DefaultOptions);
|
||||
InitializeProtocolVersion = initializeRequest?.ProtocolVersion;
|
||||
_ = WriteAsync(new JsonRpcResponse
|
||||
{
|
||||
Id = initReq.Id,
|
||||
|
|
@ -231,7 +271,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
{
|
||||
ProtocolVersion = serverNegotiatedVersion,
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" },
|
||||
ServerInfo = new Implementation { Name = "initialize-handshake-test-server", Version = "1.0.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
break;
|
||||
|
|
@ -243,7 +283,105 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) :
|
|||
|
||||
private sealed class TransportChannel(
|
||||
Channel<JsonRpcMessage> incoming,
|
||||
LegacyServerTestTransport parent) : ITransport
|
||||
InitializeHandshakeServerTestTransport parent) : ITransport
|
||||
{
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => incoming.Reader;
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
public string? SessionId => null;
|
||||
|
||||
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
parent.HandleOutgoingMessage(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
incoming.Writer.TryComplete();
|
||||
IsConnected = false;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PerRequestMetadataRetryTestTransport : IClientTransport
|
||||
{
|
||||
private readonly Channel<JsonRpcMessage> _incomingToClient = Channel.CreateUnbounded<JsonRpcMessage>();
|
||||
|
||||
public string Name => "per-request-metadata-retry-test-transport";
|
||||
|
||||
public int ServerDiscoverRequests { get; private set; }
|
||||
|
||||
public bool InitializeReceived { get; private set; }
|
||||
|
||||
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ITransport transport = new TransportChannel(_incomingToClient, this);
|
||||
return Task.FromResult(transport);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
private void HandleOutgoingMessage(JsonRpcMessage message)
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq:
|
||||
ServerDiscoverRequests++;
|
||||
|
||||
if (ServerDiscoverRequests == 1)
|
||||
{
|
||||
_ = WriteAsync(new JsonRpcError
|
||||
{
|
||||
Id = discoverReq.Id,
|
||||
Error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
|
||||
Message = "Unsupported protocol version",
|
||||
Data = CreateUnsupportedProtocolVersionData(),
|
||||
},
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = WriteAsync(new JsonRpcResponse
|
||||
{
|
||||
Id = discoverReq.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case JsonRpcRequest { Method: RequestMethods.Initialize }:
|
||||
InitializeReceived = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAsync(JsonRpcMessage message)
|
||||
=> _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask();
|
||||
|
||||
private static JsonElement CreateUnsupportedProtocolVersionData()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData
|
||||
{
|
||||
Requested = McpProtocolVersions.July2026ProtocolVersion,
|
||||
Supported = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
}, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class TransportChannel(
|
||||
Channel<JsonRpcMessage> incoming,
|
||||
PerRequestMetadataRetryTestTransport parent) : ITransport
|
||||
{
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => incoming.Reader;
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
public async Task Client_ListTools_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion });
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion });
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
|
||||
|
||||
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
public async Task Client_ListResources_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion });
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
|
||||
|
||||
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta()
|
||||
{
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion });
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
|
||||
|
||||
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
// server/discover has no public List-style helper; we drive it via SendRequestAsync directly,
|
||||
// which still flows through the client's per-request _meta injector.
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion });
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
|
||||
|
||||
// Hook the server-side handler invocation via a notification handler is awkward here; assert
|
||||
// instead by sending the request and parsing the wire-shape echo from the response context.
|
||||
|
|
@ -136,7 +136,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
|
||||
Assert.NotNull(response.Result);
|
||||
var discover = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions)!;
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions);
|
||||
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, discover.SupportedVersions);
|
||||
|
||||
// The server enforces the per-request envelope shape; if the client had omitted _meta, the
|
||||
// request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The
|
||||
|
|
@ -144,11 +144,11 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_ListTools_DoesNotEmitMeta()
|
||||
public async Task InitializeHandshakeClient_ListTools_DoesNotEmitMeta()
|
||||
{
|
||||
// Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575
|
||||
// Sanity guard: a client on the session-supporting initialize-handshake protocol must NOT emit the SEP-2575
|
||||
// envelope. The injector is gated on the negotiated protocol version; if it ever started writing
|
||||
// those keys on a legacy request, every legacy server would reject it.
|
||||
// those keys on an initialize-handshake request, every initialize-handshake server would reject it.
|
||||
StartServer();
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
|
||||
|
|
@ -175,6 +175,6 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase
|
|||
$"Missing clientCapabilities key on {method} _meta envelope");
|
||||
|
||||
// The protocolVersion value must match the negotiated 2026-07-28 protocol version.
|
||||
Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue<string>());
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : Logged
|
|||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
Capabilities = new ServerCapabilities(),
|
||||
SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion],
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
ServerInfo = new Implementation
|
||||
{
|
||||
Name = "NopTransport",
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ public class McpClientTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task AsClientLoggerProvider_MessagesSentToClient()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion });
|
||||
|
||||
ILoggerProvider loggerProvider = Server.AsClientLoggerProvider();
|
||||
Assert.Throws<ArgumentNullException>("categoryName", () => loggerProvider.CreateLogger(null!));
|
||||
|
|
@ -765,7 +765,7 @@ public class McpClientTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion });
|
||||
|
||||
// Should not throw
|
||||
await client.SetLoggingLevelAsync(
|
||||
|
|
@ -795,9 +795,9 @@ public class McpClientTests : ClientServerTestBase
|
|||
[Fact]
|
||||
public async Task ServerCanPingClient()
|
||||
{
|
||||
// ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client
|
||||
// to a legacy protocol version to exercise the server-initiated ping round-trip.
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" });
|
||||
// ping is available only on initialize-handshake revisions (removed in the 2026-07-28 protocol
|
||||
// per SEP-2575), so pin the client to exercise the server-initiated ping round-trip.
|
||||
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion });
|
||||
|
||||
var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping };
|
||||
var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ public class McpRequestHeadersTests
|
|||
[InlineData("2024-11-05", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected)
|
||||
public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version));
|
||||
Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue