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:
parent
6001d5ac7a
commit
324ccd83c3
2 changed files with 780 additions and 391 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue