Tighten draft protocol negotiation boundaries (#1692)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
a8d374b280
commit
fd38f51e27
12 changed files with 1755 additions and 589 deletions
|
|
@ -108,7 +108,7 @@ internal sealed class StatelessConformanceServer : IAsyncDisposable
|
|||
/// <remarks>
|
||||
/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle.
|
||||
/// It is gated on the installed conformance
|
||||
/// package version (>= 0.2.0). The stateless server is
|
||||
/// package's scenario list. The stateless server is
|
||||
/// started only after the gates pass, so a skipped run binds no port.
|
||||
/// </remarks>
|
||||
public class CachingConformanceTests(ITestOutputHelper output)
|
||||
|
|
@ -119,7 +119,7 @@ public class CachingConformanceTests(ITestOutputHelper output)
|
|||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasCachingScenario(),
|
||||
"SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0).");
|
||||
"SEP-2549 caching conformance scenario is not available in the installed conformance package.");
|
||||
|
||||
await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public class ClientConformanceTests
|
|||
// Public static property required for SkipUnless attribute
|
||||
public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
|
||||
public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios();
|
||||
public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario();
|
||||
public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario();
|
||||
|
||||
public ClientConformanceTests(ITestOutputHelper output)
|
||||
|
|
@ -45,7 +46,7 @@ public class ClientConformanceTests
|
|||
[InlineData("auth/resource-mismatch")]
|
||||
[InlineData("auth/pre-registration")]
|
||||
|
||||
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata).
|
||||
// Backcompat: 2025-03-26 OAuth flows (no per-request metadata, root-location metadata).
|
||||
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
|
||||
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
||||
|
||||
|
|
@ -63,8 +64,18 @@ public class ClientConformanceTests
|
|||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
// Per-request metadata (SEP-2575)
|
||||
[Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))]
|
||||
public async Task RunConformanceTest_RequestMetadata()
|
||||
{
|
||||
var result = await RunClientConformanceScenario("request-metadata");
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[InlineData("http-standard-headers")]
|
||||
[InlineData("http-invalid-tool-headers")]
|
||||
public async Task RunConformanceTest_Sep2243(string scenario)
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsUnionIntegerCanonicalForm()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body
|
||||
// carries the decimal form 42.0. The server must treat the union type as integer and match.
|
||||
|
|
@ -135,7 +135,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsUnionIntegerOutsideSafeRange()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
var callJson = CallTool("union_test", """{"priority":9007199254740993}""");
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsExponentBodyMatchingDecimalHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100".
|
||||
var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -177,7 +177,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243: servers MUST accept extra whitespace around header values
|
||||
// and compare the trimmed value to the request body.
|
||||
|
|
@ -201,7 +201,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243: servers MUST accept extra whitespace around header values
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -224,7 +224,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Send a tools/call with an empty string param that has an x-mcp-header.
|
||||
// The header should be present with an empty value, matching the body's empty string.
|
||||
|
|
@ -248,7 +248,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Send a tools/call where the body has a non-empty value but the header is empty
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}""");
|
||||
|
|
@ -271,7 +271,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsBase64EncodedHeaderWithControlChars()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Encode a value with a newline control character using Base64
|
||||
var valueWithNewline = "line1\nline2";
|
||||
|
|
@ -297,7 +297,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
|
||||
// losing precision through a double conversion.
|
||||
|
|
@ -326,7 +326,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Per SEP-2243 integer values MUST be within the JavaScript safe integer range.
|
||||
// A matching header and body that are both outside the range must still be rejected.
|
||||
|
|
@ -355,7 +355,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and
|
||||
// "4.2e1" are preserved in the body exactly as another SDK might serialize them.
|
||||
|
|
@ -382,7 +382,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue)
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// For an integer-typed parameter a non-whole numeric value is invalid and must be rejected
|
||||
// even when the header and body strings are byte-for-byte identical (it must not slip through
|
||||
|
|
@ -407,7 +407,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Header says "99" but body says priority:42 — must reject even with numeric comparison
|
||||
var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
|
|
@ -427,17 +427,17 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_SkipsHeaderValidation_ForLegacyVersion()
|
||||
public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithLegacyVersionAsync();
|
||||
await InitializeWithInitializeHandshakeVersionAsync();
|
||||
|
||||
// With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
|
||||
// With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched.
|
||||
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
|
||||
// Send the WRONG header value. This should still succeed because the version is legacy.
|
||||
// Send the WRONG header value. This should still succeed because the version uses initialize.
|
||||
request.Headers.Add("MCP-Protocol-Version", "2025-11-25");
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "header_test");
|
||||
|
|
@ -451,7 +451,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
public async Task Server_RejectsInvalidUtf8EncodedHeaderValue()
|
||||
{
|
||||
await StartAsync();
|
||||
await InitializeWithJuly2026ProtocolVersionAsync();
|
||||
await ProbeWithJuly2026ProtocolVersionAsync();
|
||||
|
||||
// Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers
|
||||
// instead of properly base64-encoding non-ASCII values.
|
||||
|
|
@ -561,40 +561,39 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
[InlineData("2024-11-05", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected)
|
||||
public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version));
|
||||
Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private async Task InitializeWithJuly2026ProtocolVersionAsync()
|
||||
private async Task ProbeWithJuly2026ProtocolVersionAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
|
||||
request.Content = JsonContent(DiscoverRequestJuly2026Protocol);
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "initialize");
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a
|
||||
// mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28
|
||||
// so each one is handled independently.
|
||||
// Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request
|
||||
// metadata instead of initialize.
|
||||
}
|
||||
|
||||
private async Task InitializeWithLegacyVersionAsync()
|
||||
private async Task InitializeWithInitializeHandshakeVersionAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return
|
||||
// Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return
|
||||
// a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision.
|
||||
}
|
||||
|
||||
|
|
@ -602,22 +601,24 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
|
|||
|
||||
private long _lastRequestId = 1;
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}")
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$$"""
|
||||
{"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}}
|
||||
""";
|
||||
var meta = includePerRequestMetadata
|
||||
? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}"
|
||||
: "";
|
||||
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" +
|
||||
toolName + "\",\"arguments\":" + arguments + meta + "}}";
|
||||
}
|
||||
|
||||
private static string InitializeRequest => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
|
||||
""";
|
||||
|
||||
private static string InitializeRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ using System.Text.Json.Serialization.Metadata;
|
|||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These
|
||||
/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize
|
||||
/// handshake over Streamable HTTP. These
|
||||
/// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's
|
||||
/// <c>simple-streamablehttp-stateless</c> returns a JSON-RPC error envelope in a <c>400</c> body
|
||||
/// on a 2026-07-28 probe; vanilla Go does the same on <c>POST /</c>) so the client's HTTP-fallback
|
||||
|
|
@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
/// <item>
|
||||
/// <see cref="StreamableHttpClientSessionTransport"/> only surfaced the three error codes
|
||||
/// introduced by the 2026-07-28 revision (<c>-32022</c>, <c>-32021</c>, <c>-32020</c>) as <see cref="McpProtocolException"/>;
|
||||
/// any other JSON-RPC error code in a <c>400</c> body (e.g. <c>-32600</c> from a legacy server
|
||||
/// any other JSON-RPC error code in a <c>400</c> body (e.g. <c>-32600</c> from an initialize-handshake server
|
||||
/// that doesn't understand the 2026-07-28 <c>_meta</c> envelope) threw <see cref="HttpRequestException"/>
|
||||
/// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger
|
||||
/// on ANY non-modern JSON-RPC error in a <c>400</c> body.
|
||||
/// on ANY non-SEP-2575 JSON-RPC error in a <c>400</c> body.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="AutoDetectingClientSessionTransport"/> treated any non-2xx HTTP response as a
|
||||
/// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked
|
||||
/// application-level errors (including the three modern codes) because the SSE GET would
|
||||
/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would
|
||||
/// either fail with "session id required" or succeed against a different endpoint and lose
|
||||
/// the actual signal.
|
||||
/// </item>
|
||||
|
|
@ -86,11 +87,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
/// <summary>
|
||||
/// Mimics Python's <c>simple-streamablehttp-stateless</c> on a 2026-07-28 probe: returns
|
||||
/// <c>400</c> + JSON-RPC <c>-32600</c> ("Bad Request: Unsupported protocol version") for the
|
||||
/// initial <c>server/discover</c>, then performs a normal legacy <c>initialize</c> handshake
|
||||
/// initial <c>server/discover</c>, then performs a normal <c>initialize</c> handshake
|
||||
/// when the client falls back.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError()
|
||||
public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
|
|
@ -107,7 +108,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
return;
|
||||
}
|
||||
|
||||
// 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with
|
||||
// 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with
|
||||
// a -32600 envelope (matches Python's wire shape verified in cross-SDK testing).
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
{
|
||||
|
|
@ -115,7 +116,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
return;
|
||||
}
|
||||
|
||||
// Legacy initialize: respond with the highest version the legacy server speaks.
|
||||
// Initialize handshake: respond with the highest version this server speaks.
|
||||
if (request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
var response = new JsonRpcResponse
|
||||
|
|
@ -123,9 +124,9 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2025-06-18",
|
||||
ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion,
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "legacy", Version = "1.0" },
|
||||
ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
|
|
@ -157,11 +158,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to a legacy server.
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
// Sanity: subsequent traffic still works post-fallback.
|
||||
var tools = await client.ListToolsAsync(cancellationToken: ct);
|
||||
|
|
@ -170,7 +171,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
|
||||
/// <summary>
|
||||
/// Mimics vanilla Go: returns <c>400</c> + JSON-RPC <c>-32022</c> with
|
||||
/// <c>data.supported[]</c> on a 2026-07-28 probe so the client retries legacy
|
||||
/// <c>data.supported[]</c> on a 2026-07-28 probe so the client retries
|
||||
/// <c>initialize</c> with one of the advertised versions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
|
@ -197,8 +198,8 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
// Use the typed payload type so the source-generated serializer can handle it.
|
||||
var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData
|
||||
{
|
||||
Supported = new List<string> { "2025-11-25" },
|
||||
Requested = "2026-07-28",
|
||||
Supported = new List<string> { McpProtocolVersions.November2025ProtocolVersion },
|
||||
Requested = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, GetJsonTypeInfo<UnsupportedProtocolVersionErrorData>());
|
||||
|
||||
var rpcError = new JsonRpcError
|
||||
|
|
@ -225,7 +226,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2025-11-25",
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" },
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
|
|
@ -244,16 +245,16 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to a legacy server.
|
||||
// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
|
||||
loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 400 with a JSON-RPC <c>-32020 HeaderMismatch</c> envelope must be surfaced to the
|
||||
/// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope.
|
||||
/// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback()
|
||||
|
|
@ -293,11 +294,133 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion,
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
});
|
||||
|
||||
Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode);
|
||||
Assert.False(initializeReceived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
string? toolsListSessionId = null;
|
||||
|
||||
await StartServerAsync(async context =>
|
||||
{
|
||||
var message = await JsonSerializer.DeserializeAsync(
|
||||
context.Request.Body,
|
||||
GetJsonTypeInfo<JsonRpcMessage>(),
|
||||
ct);
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request)
|
||||
{
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" },
|
||||
TimeToLive = TimeSpan.Zero,
|
||||
CacheScope = CacheScope.Private,
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session";
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest)
|
||||
{
|
||||
toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString();
|
||||
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = toolsListRequest.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
});
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: ct);
|
||||
|
||||
Assert.Null(client.SessionId);
|
||||
Assert.Equal("", toolsListSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
string? discoverSessionId = null;
|
||||
|
||||
await StartServerAsync(async context =>
|
||||
{
|
||||
var message = await JsonSerializer.DeserializeAsync(
|
||||
context.Request.Body,
|
||||
GetJsonTypeInfo<JsonRpcMessage>(),
|
||||
ct);
|
||||
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request)
|
||||
{
|
||||
discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString();
|
||||
|
||||
var response = new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new DiscoverResult
|
||||
{
|
||||
SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion],
|
||||
Capabilities = new ServerCapabilities(),
|
||||
ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" },
|
||||
TimeToLive = TimeSpan.Zero,
|
||||
CacheScope = CacheScope.Private,
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||
});
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
KnownSessionId = "legacy-session",
|
||||
OwnsSession = false,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||
|
||||
Assert.Equal("", discoverSessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
|
||||
/// <summary>
|
||||
/// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server
|
||||
/// suppresses the <c>Mcp-Session-Id</c> header for those requests and returns structured
|
||||
/// does not issue <c>Mcp-Session-Id</c> for those requests and returns structured
|
||||
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> errors instead of plain 400s.
|
||||
/// </summary>
|
||||
public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
|
|
@ -52,12 +52,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
// On a stateless server, server/discover succeeds without creating a session.
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -70,15 +70,15 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567),
|
||||
// so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that
|
||||
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back
|
||||
// to the legacy initialize handshake.
|
||||
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back
|
||||
// to the initialize handshake.
|
||||
await StartAsync(stateless: false);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -93,22 +93,22 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
var dataElement = (JsonElement)rpcError.Error.Data!;
|
||||
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(errorData);
|
||||
Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested);
|
||||
// The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version.
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested);
|
||||
// The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version.
|
||||
Assert.NotEmpty(errorData.Supported);
|
||||
Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported);
|
||||
Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError()
|
||||
{
|
||||
await StartAsync();
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31");
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -128,23 +128,23 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Request_WithMcpSessionIdHeader_IsRejected()
|
||||
public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId()
|
||||
{
|
||||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
|
||||
// a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the
|
||||
// Stateless setting.
|
||||
await StartAsync();
|
||||
// a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs.
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
var content = new StringContent(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
|
||||
DiscoverRequestJuly2026Protocol,
|
||||
Encoding.UTF8, "application/json");
|
||||
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains("Mcp-Session-Id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -152,11 +152,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -164,12 +165,13 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -177,11 +179,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -189,11 +192,16 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) :
|
|||
{
|
||||
await StartAsync();
|
||||
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
|
||||
|
||||
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Equal(["POST"], response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ namespace ModelContextProtocol.AspNetCore.Tests;
|
|||
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
|
||||
/// is <c>false</c>). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports
|
||||
/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with <c>-32022 UnsupportedProtocolVersion</c>.
|
||||
/// The client must then auto-downgrade to the legacy <c>initialize</c> handshake, obtain the stateful session
|
||||
/// The client must then auto-downgrade to the <c>initialize</c> handshake, obtain the stateful session
|
||||
/// the server author opted into, and continue to work, including a server→client elicitation round-trip
|
||||
/// resolved over the stateful session via the legacy backcompat resolver.
|
||||
/// resolved over the stateful session via the initialize-handshake backcompat resolver.
|
||||
/// </summary>
|
||||
public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
|
||||
{
|
||||
|
|
@ -38,7 +38,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
private static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
|
||||
{
|
||||
// Server→client round-trip: only works when the session is stateful, which is exactly what
|
||||
// the legacy fallback re-establishes for the 2026-07-28-first client.
|
||||
// the initialize fallback re-establishes for the 2026-07-28-first client.
|
||||
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
|
||||
{
|
||||
Message = "What is your name?",
|
||||
|
|
@ -77,21 +77,21 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes
|
||||
// with server/discover before falling back to a legacy initialize handshake.
|
||||
// with server/discover before falling back to an initialize handshake.
|
||||
var clientOptions = new McpClientOptions();
|
||||
configureClient?.Invoke(clientOptions);
|
||||
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork()
|
||||
public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork()
|
||||
{
|
||||
await StartStatefulServerAsync();
|
||||
|
||||
await using var client = await ConnectDefaultClientAsync();
|
||||
|
||||
// The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy.
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
// The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize.
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("greet",
|
||||
new Dictionary<string, object?> { ["name"] = "Alice" },
|
||||
|
|
@ -118,7 +118,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe
|
|||
});
|
||||
});
|
||||
|
||||
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
|
||||
|
||||
var result = await client.CallToolAsync("greet_via_elicit",
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<ItemGroup>
|
||||
<Compile Include="..\Common\**\*.cs" />
|
||||
<Compile Include="..\..\src\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
<Compile Include="..\..\src\Common\McpProtocolVersions.cs" Link="McpProtocolVersions.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
// headers. No initialize handshake and no Mcp-Session-Id.
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
|
@ -261,7 +261,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream()
|
||||
{
|
||||
// Configure a server that does NOT pin 2026-07-28 so it can negotiate the current
|
||||
// protocol with a legacy client. The backcompat resolver path only runs when the
|
||||
// initialize-handshake protocol. The backcompat resolver path only runs when the
|
||||
// negotiated version is not 2026-07-28.
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
|
|
@ -302,7 +302,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
|
||||
|
||||
// Initialize with the current legacy protocol so the server's backcompat resolver runs.
|
||||
// Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs.
|
||||
var initJson = """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}}
|
||||
""";
|
||||
|
|
@ -337,7 +337,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
// the response headers arrive instead of waiting for the SSE stream to close.
|
||||
var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null)
|
||||
{
|
||||
Content = JsonContent(CallTool("backcompat-roots-tool")),
|
||||
Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)),
|
||||
};
|
||||
callRequest.Content.Headers.Add("Mcp-Method", "tools/call");
|
||||
callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool");
|
||||
|
|
@ -456,16 +456,49 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
|
|||
|
||||
private long _lastRequestId = 1;
|
||||
|
||||
private string Request(string method, string parameters = "{}")
|
||||
private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _lastRequestId);
|
||||
return $$"""
|
||||
{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}}
|
||||
""";
|
||||
var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject();
|
||||
if (includePerRequestMetadata)
|
||||
{
|
||||
AddJuly2026ProtocolMeta(paramsObj);
|
||||
}
|
||||
|
||||
var request = new JsonObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["id"] = id,
|
||||
["method"] = method,
|
||||
["params"] = paramsObj,
|
||||
};
|
||||
|
||||
return request.ToJsonString();
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}") =>
|
||||
private static void AddJuly2026ProtocolMeta(JsonObject paramsObj)
|
||||
{
|
||||
if (paramsObj["_meta"] is not JsonObject meta)
|
||||
{
|
||||
meta = [];
|
||||
paramsObj["_meta"] = meta;
|
||||
}
|
||||
|
||||
meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion;
|
||||
meta[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "MrtrTestClient",
|
||||
["version"] = "1.0",
|
||||
};
|
||||
|
||||
if (meta[MetaKeys.ClientCapabilities] is not JsonObject)
|
||||
{
|
||||
meta[MetaKeys.ClientCapabilities] = new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) =>
|
||||
Request("tools/call", $$"""
|
||||
{"name":"{{toolName}}","arguments":{{arguments}}}
|
||||
""");
|
||||
""", includePerRequestMetadata);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
|
||||
private WebApplication? _app;
|
||||
|
||||
private async Task StartAsync()
|
||||
private async Task StartAsync(string? protocolVersion = null)
|
||||
{
|
||||
Builder.Services
|
||||
.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" };
|
||||
options.ProtocolVersion = protocolVersion;
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]);
|
||||
|
|
@ -82,7 +83,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
return JsonNode.Parse(body)!;
|
||||
}
|
||||
|
||||
private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) =>
|
||||
private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) =>
|
||||
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
|
||||
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
|
||||
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
|
||||
|
|
@ -97,7 +98,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
|
@ -119,14 +120,14 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported);
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
|
||||
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
|
||||
|
|
@ -135,6 +136,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion()
|
||||
{
|
||||
await StartAsync(McpProtocolVersions.July2026ProtocolVersion);
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022()
|
||||
{
|
||||
|
|
@ -151,7 +170,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload
|
||||
// listing the supported versions. The dual-era client uses this to switch versions without fallback.
|
||||
// listing the supported versions. The dual-path client uses this to switch versions without fallback.
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
|
||||
|
|
@ -160,7 +179,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.NotNull(data);
|
||||
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
|
||||
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported);
|
||||
Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -171,17 +190,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
// The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a
|
||||
// different (still individually supported) version. Per SEP-2575 the server MUST reject the
|
||||
// disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body
|
||||
// checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a
|
||||
// legacy one and falling back to the initialize handshake.
|
||||
// checks) so a conformant client on this revision surfaces the error instead of mistaking the
|
||||
// per-request-metadata server for an initialize-handshake one and falling back to initialize.
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" +
|
||||
July2026ProtocolMetaFragment("2025-11-25") + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
|
|
@ -203,7 +223,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
|
|
@ -214,11 +234,87 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyInitialize_StillSucceeds_OnDefaultServer()
|
||||
public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion()
|
||||
{
|
||||
await StartAsync(McpProtocolVersions.November2025ProtocolVersion);
|
||||
|
||||
var body =
|
||||
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
|
||||
July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue<string>());
|
||||
|
||||
var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
|
||||
Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}";
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", RequestMethods.Initialize);
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeHandshake_StillSucceeds_OnDefaultServer()
|
||||
{
|
||||
await StartAsync();
|
||||
|
||||
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
|
@ -243,4 +339,3 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
|
|||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
|
|||
var body = july2026Protocol
|
||||
? """
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""".Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion)
|
||||
""".Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion)
|
||||
: """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}""";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||||
|
|
@ -125,7 +125,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
|
|||
|
||||
if (july2026Protocol)
|
||||
{
|
||||
request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
|
||||
request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "blockingTool");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ public class ServerConformanceTests(
|
|||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
"SEP-2243 conformance scenarios are not available in the installed conformance package.");
|
||||
|
||||
// SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless
|
||||
// lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call
|
||||
|
|
@ -196,7 +196,7 @@ public class ServerConformanceTests(
|
|||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(
|
||||
!NodeHelpers.HasSep2243Scenarios(),
|
||||
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
|
||||
"SEP-2243 conformance scenarios are not available in the installed conformance package.");
|
||||
|
||||
await using var server = await StatelessConformanceServer.StartAsync(
|
||||
TestContext.Current.CancellationToken, basePort: 3024);
|
||||
|
|
@ -245,7 +245,7 @@ public class ServerConformanceTests(
|
|||
public async Task RunMrtrConformanceTest(string scenario)
|
||||
{
|
||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package.");
|
||||
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package.");
|
||||
|
||||
var result = await RunStatelessConformanceTestAsync(
|
||||
$"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28");
|
||||
|
|
|
|||
|
|
@ -951,12 +951,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server.
|
||||
await StartAsync(stateless: true);
|
||||
|
||||
// Initialize with the 2026-07-28 protocol version to enable header validation
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
// Probe with the 2026-07-28 protocol version to enable header validation.
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request without Mcp-Method header — should be rejected
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
// Deliberately omit Mcp-Method header
|
||||
|
||||
|
|
@ -968,11 +968,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader()
|
||||
{
|
||||
await StartAsync(stateless: true);
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request but set Mcp-Method to wrong value
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method
|
||||
|
||||
|
|
@ -984,11 +984,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader()
|
||||
{
|
||||
await StartAsync(stateless: true);
|
||||
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync();
|
||||
|
||||
// Send a tools/call request with correct Mcp-Method and Mcp-Name headers
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "tools/call");
|
||||
request.Headers.Add("Mcp-Name", "echo");
|
||||
|
|
@ -998,12 +998,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyVersion_DoesNotRequireMcpMethodHeader()
|
||||
public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader()
|
||||
{
|
||||
await StartAsync();
|
||||
await CallInitializeAndValidateAsync();
|
||||
|
||||
// With the legacy version, Mcp-Method header is not required
|
||||
// With the initialize-handshake version, Mcp-Method header is not required.
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
|
||||
request.Headers.Add("MCP-Protocol-Version", "2025-03-26");
|
||||
|
|
@ -1013,25 +1013,25 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync()
|
||||
private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync()
|
||||
{
|
||||
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "");
|
||||
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
|
||||
request.Content = JsonContent(DiscoverRequestJuly2026Protocol);
|
||||
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
|
||||
request.Headers.Add("Mcp-Method", "initialize");
|
||||
request.Headers.Add("Mcp-Method", "server/discover");
|
||||
|
||||
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
var rpcResponse = await AssertSingleSseResponseAsync(response);
|
||||
AssertServerInfo(rpcResponse);
|
||||
AssertDiscoverServerInfo(rpcResponse);
|
||||
|
||||
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id.
|
||||
// Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently.
|
||||
// Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request
|
||||
// metadata instead of initialize.
|
||||
}
|
||||
|
||||
private static string InitializeRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
|
||||
private static string DiscoverRequestJuly2026Protocol => """
|
||||
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
|
|
@ -1103,10 +1103,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
""";
|
||||
}
|
||||
|
||||
private string CallTool(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$"""
|
||||
{"name":"{{toolName}}","arguments":{{arguments}}}
|
||||
""");
|
||||
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false)
|
||||
{
|
||||
var meta = includePerRequestMetadata
|
||||
? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}"
|
||||
: "";
|
||||
|
||||
return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}");
|
||||
}
|
||||
|
||||
private string CallToolWithProgressToken(string toolName, string arguments = "{}") =>
|
||||
Request("tools/call", $$$"""
|
||||
|
|
@ -1126,6 +1130,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
|||
return initializeResult;
|
||||
}
|
||||
|
||||
private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var discoverResult = AssertType<DiscoverResult>(rpcResponse.Result);
|
||||
Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name);
|
||||
Assert.Equal("73", discoverResult.ServerInfo.Version);
|
||||
return discoverResult;
|
||||
}
|
||||
|
||||
private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse)
|
||||
{
|
||||
var callToolResponse = AssertType<CallToolResult>(rpcResponse.Result);
|
||||
|
|
|
|||
Loading…
Reference in a new issue