Relax legacy request metadata validation (#1852)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
This commit is contained in:
Stephen Halter 2026-09-13 16:30:42 -07:00 • committed by GitHub
commit 324ccd83c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 780 additions and 391 deletions

View file

@ -132,6 +132,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
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);
var capabilities = json["result"]!["capabilities"]!.AsObject();
Assert.False(capabilities.ContainsKey("logging"));
Assert.True(capabilities.ContainsKey("tools"));
// 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.
@ -349,12 +352,19 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported);
}
[Fact]
public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602()
[Theory]
[InlineData(null)]
[InlineData("null")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("42")]
public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson)
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}";
var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ",";
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty +
@"""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);
@ -370,8 +380,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue<string>(), StringComparison.Ordinal);
}
[Theory]
[InlineData("{}", McpErrorCode.InvalidParams)]
[InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)]
[InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)]
public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError)
{
await StartAsync(McpProtocolVersions.July2026ProtocolVersion);
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue<int>());
}
[Fact]
public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020()
public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata()
{
await StartAsync();
@ -381,10 +407,61 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
// The 2025-03-26 header default only applies when the server has "no other way to identify the
// version". The body metadata is another way, so the transport does not reject the request on the
// strength of a reserved _meta value. stdio, which has no header at all, behaves identically.
Assert.Equal(HttpStatusCode.OK, 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);
Assert.NotNull(json["result"]);
}
[Fact]
public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022()
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}";
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);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue<string>());
}
[Fact]
public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored()
{
await StartAsync();
// No header and no established era means legacy handling, where the reserved namespace is opaque.
// A value we cannot read is not a version claim, so it must not turn a legacy request into an error.
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.NotNull(json["result"]!["tools"]);
}
[Fact]
public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored()
{
await StartAsync();
// A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783.
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.NotNull(json["result"]!["tools"]);
}
[Fact]
@ -405,11 +482,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
}
[Fact]
public async Task InitializeHandshake_StillSucceeds_OnDefaultServer()
public async Task InitializeHandshake_IgnoresFutureMetadata_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""}}}";
var body =
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," +
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
@ -453,6 +532,29 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result.");
}
[Theory]
[InlineData(@"""2025-11-25""")]
[InlineData(@"""2026-07-28""")]
[InlineData(@"""9999-99-99""")]
[InlineData("{}")]
public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson)
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," +
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," +
@"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion);
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
}
[Fact]
public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405()
{
@ -523,6 +625,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
{
private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1);
[McpServerTool(Name = "legacy_meta_probe")]
public static string LegacyMetaProbe(RequestContext<CallToolRequestParams> context)
{
var requestContext = context.JsonRpcRequest.Context;
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion);
Assert.Null(requestContext?.ClientInfo);
Assert.Null(requestContext?.ClientCapabilities);
Assert.Null(context.Server.ClientInfo);
Assert.Null(context.Server.ClientCapabilities);
return "future-metadata-ignored";
}
[McpServerTool(Name = "requires_sampling")]
public static string RequiresSampling() =>
throw new MissingRequiredClientCapabilityException(

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
@ -9,6 +10,7 @@ using System.Diagnostics;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Channels;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -154,6 +156,43 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType<TextContentBlock>(toolContent).Text);
}
[Fact]
public async Task ClientCapabilities_AreAvailableFromInjectedServer()
{
await StartAsync();
var clientOptions = new McpClientOptions();
clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException();
clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException();
clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException();
await using var client = await ConnectMcpClientAsync(clientOptions);
var toolResponse = await client.CallToolAsync(
"getClientCapabilities",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(
"sampling|roots|elicitation",
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
}
[Fact]
public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode()
{
await StartAsync();
var clientOptions = new McpClientOptions();
clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException();
clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException();
await using var client = await ConnectMcpClientAsync(clientOptions);
var toolResponse = await client.CallToolAsync(
"testInterceptedRequests",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(
"intercepted-sample|Seattle",
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
}
[Fact]
public async Task UnsolicitedNotification_Fails_WithInvalidOperationException()
{
@ -575,8 +614,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode.";
// Even when the client has sampling support, it should not be advertised in stateless mode.
Assert.Null(server.ClientCapabilities);
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
Assert.NotNull(server.ClientCapabilities?.Sampling);
var asSamplingChatClientEx = Assert.Throws<InvalidOperationException>(() => server.AsSamplingChatClient());
Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message);
@ -596,8 +635,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedRootsErrorMessage = "Roots are not supported in stateless mode.";
// Even when the client has roots support, it should not be advertised in stateless mode.
Assert.Null(server.ClientCapabilities);
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
Assert.NotNull(server.ClientCapabilities?.Roots);
var requestRootsEx = Assert.Throws<InvalidOperationException>(() => server.RequestRootsAsync(new()));
Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message);
@ -614,8 +653,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode.";
// Even when the client has elicitation support, it should not be advertised in stateless mode.
Assert.Null(server.ClientCapabilities);
// The declaration is visible to application code, but it cannot make a session-dependent request safe.
Assert.NotNull(server.ClientCapabilities?.Elicitation);
var requestElicitationEx = await Assert.ThrowsAsync<InvalidOperationException>(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask());
Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message);
@ -627,6 +666,39 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
return ex.Message;
}
[McpServerTool(Name = "getClientCapabilities")]
public static string GetClientCapabilities(McpServer server) =>
string.Join(
'|',
server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling",
server.ClientCapabilities?.Roots is null ? "no-roots" : "roots",
server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation");
[McpServerTool(Name = "testInterceptedRequests")]
public static async Task<string> TestInterceptedRequests(McpServer server)
{
// Background task execution replaces the server-to-client channel with an interceptor that parks the
// request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not
// depend on session affinity, which is why these requests are allowed through in stateless mode.
#pragma warning disable MCPEXP002
var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch
{
RequestMethods.SamplingCreateMessage =>
"""{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""",
RequestMethods.ElicitationCreate =>
"""{"action":"accept","content":{"city":"Seattle"}}""",
_ => throw new UnreachableException(),
})));
#pragma warning restore MCPEXP002
var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?");
var elicitResult = await intercepted.ElicitAsync<CityForm>(
"Which city?",
options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options });
return $"{samplingResponse.Text}|{elicitResult.Content?.City}";
}
[McpServerTool(Name = "testScope")]
public static string? TestScope(ScopedService scopedService) => scopedService.State;
@ -640,3 +712,12 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
public void Report(T value) => handler(value);
}
}
public class CityForm
{
public string? City { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(CityForm))]
internal partial class StatelessInterceptorJsonContext : JsonSerializerContext;