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

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