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:
Stephen Halter 2026-07-12 14:30:49 -07:00 • committed by GitHub
commit fd38f51e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1755 additions and 589 deletions

View file

@ -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 &lt;path-to-conformance&gt;</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 &lt;path-to-conformance&gt;</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 &lt;path-to-conformance&gt;</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)
{

View file

@ -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);

View file

@ -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)

View file

@ -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
}

View file

@ -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);
}
}

View file

@ -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":{}}}}
""";
}

View file

@ -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);

View file

@ -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>

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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");
}

View file

@ -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");

View file

@ -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);

View file

@ -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";

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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>());
}
}

View file

@ -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",

View file

@ -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);

View file

@ -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