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
5 changed files with 780 additions and 391 deletions
|
|
@ -137,6 +137,53 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
Assert.Contains("bar baz", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState()
|
||||
{
|
||||
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> context) =>
|
||||
{
|
||||
Assert.Null(context.JsonRpcRequest.Context?.ClientInfo);
|
||||
Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities);
|
||||
|
||||
Assert.Equal("initialized-client", context.Server.ClientInfo?.Name);
|
||||
Assert.NotNull(context.Server.ClientCapabilities?.Elicitation);
|
||||
Assert.Null(context.Server.ClientCapabilities?.Sampling);
|
||||
|
||||
return "ok";
|
||||
},
|
||||
new() { Name = "legacy_meta_tool" }));
|
||||
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = LatestStableVersion,
|
||||
ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" },
|
||||
Handlers = new McpClientHandlers
|
||||
{
|
||||
ElicitationHandler = (_, _) => new ValueTask<ElicitResult>(new ElicitResult()),
|
||||
},
|
||||
};
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "legacy_meta_tool",
|
||||
Meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(
|
||||
new Implementation { Name = "request-client", Version = "2.0.0" },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
[MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode(
|
||||
new ClientCapabilities { Sampling = new SamplingCapability() },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
},
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("ok", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities()
|
||||
{
|
||||
|
|
@ -250,9 +297,9 @@ public class McpClientMetaTests : ClientServerTestBase
|
|||
ClientCapabilities? handlerObservedCapabilities = null;
|
||||
|
||||
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> context) =>
|
||||
(McpServer server) =>
|
||||
{
|
||||
handlerObservedCapabilities = context.Server.ClientCapabilities;
|
||||
handlerObservedCapabilities = server.ClientCapabilities;
|
||||
return "ok";
|
||||
},
|
||||
new() { Name = "capability_probe_tool" }));
|
||||
|
|
|
|||
|
|
@ -289,11 +289,20 @@ public class McpServerTests : LoggedTest
|
|||
Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version);
|
||||
Assert.Equal("2024-11-05", result.ProtocolVersion);
|
||||
Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion);
|
||||
Assert.True(Assert.IsType<JsonObject>(response)["capabilities"]!.AsObject().ContainsKey("logging"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion()
|
||||
[Theory]
|
||||
[InlineData("2025-11-25", "\"2025-03-26\"", null)]
|
||||
[InlineData("2025-11-25", "\"2026-07-28\"", null)]
|
||||
[InlineData("2025-11-25", "{}", null)]
|
||||
[InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)]
|
||||
[InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)]
|
||||
[InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)]
|
||||
[InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)]
|
||||
public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata(
|
||||
string transportVersion, string metadataVersionJson, McpErrorCode? expectedError)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new TestServerTransport();
|
||||
|
|
@ -303,71 +312,44 @@ public class McpServerTests : LoggedTest
|
|||
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||
var runTask = server.RunAsync(ct);
|
||||
|
||||
var rejectedResponse = new TaskCompletionSource<JsonRpcError>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var acceptedResponse = new TaskCompletionSource<JsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var response = new TaskCompletionSource<JsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
transport.OnMessageSent = message =>
|
||||
{
|
||||
if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1")
|
||||
if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1")
|
||||
{
|
||||
rejectedResponse.TrySetResult(error);
|
||||
}
|
||||
else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2")
|
||||
{
|
||||
acceptedResponse.TrySetResult(message);
|
||||
response.TrySetResult(message);
|
||||
}
|
||||
};
|
||||
|
||||
await transport.SendClientMessageAsync(new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Method = RequestMethods.Ping,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "test-client",
|
||||
["version"] = "1.0.0",
|
||||
},
|
||||
[MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson),
|
||||
},
|
||||
},
|
||||
Context = new JsonRpcMessageContext
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
ProtocolVersion = transportVersion,
|
||||
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
|
||||
Assert.Null(server.NegotiatedProtocolVersion);
|
||||
|
||||
var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" };
|
||||
var clientCapabilities = new ClientCapabilities();
|
||||
await transport.SendClientMessageAsync(new JsonRpcRequest
|
||||
var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
if (expectedError is { } errorCode)
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion,
|
||||
[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions),
|
||||
[MetaKeys.ClientCapabilities] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
Context = new JsonRpcMessageContext
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
ClientInfo = clientInfo,
|
||||
ClientCapabilities = clientCapabilities,
|
||||
},
|
||||
}, ct);
|
||||
|
||||
await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion);
|
||||
Assert.Equal((int)errorCode, Assert.IsType<JsonRpcError>(message).Error.Code);
|
||||
Assert.Null(server.NegotiatedProtocolVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.IsType<JsonRpcResponse>(message);
|
||||
Assert.Equal(transportVersion, server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
await transport.DisposeAsync();
|
||||
await runTask;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
private readonly Pipe _serverToClient = new();
|
||||
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly McpServer _server;
|
||||
private readonly Task _serverTask;
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly StreamReader _reader;
|
||||
|
|
@ -35,14 +36,15 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging();
|
||||
serviceCollection.AddSingleton<ILoggerProvider>(XunitLoggerProvider);
|
||||
serviceCollection.AddSingleton<ILoggerProvider>(MockLoggerProvider);
|
||||
serviceCollection
|
||||
.AddMcpServer()
|
||||
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
|
||||
.WithTools<EchoTools>();
|
||||
|
||||
_services = serviceCollection.BuildServiceProvider(validateScopes: true);
|
||||
var server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = server.RunAsync(_cts.Token);
|
||||
_server = _services.GetRequiredService<McpServer>();
|
||||
_serverTask = _server.RunAsync(_cts.Token);
|
||||
|
||||
_writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true };
|
||||
_reader = new StreamReader(_serverToClient.Reader.AsStream());
|
||||
|
|
@ -69,16 +71,115 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize()
|
||||
public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var error = Assert.IsType<JsonRpcError>(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code);
|
||||
Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
|
||||
// The rejected initialize-handshake _meta request must not have established session state.
|
||||
// The advisory legacy value must not block a subsequent modern request from selecting its era.
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("true")]
|
||||
public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// An unreadable value leaves the request on the legacy path without establishing a version.
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
|
||||
var warning = Assert.Single(
|
||||
MockLoggerProvider.LogMessages,
|
||||
m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal));
|
||||
Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ServerDiscover,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = new JsonObject(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy
|
||||
// handling. This is what keeps a modern client from silently getting legacy treatment on a transport
|
||||
// with no protocol version header.
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("true")]
|
||||
public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
|
||||
var meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientCapabilities] = new JsonObject(),
|
||||
};
|
||||
if (protocolVersionJson is not null)
|
||||
{
|
||||
meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson);
|
||||
}
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = meta,
|
||||
},
|
||||
};
|
||||
|
||||
// Once the era is established the metadata is required, so an unreadable value is an error rather
|
||||
// than a reason to drop back to legacy handling.
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -110,6 +211,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -142,7 +244,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_WithReservedPerRequestMetadata_IsRejected()
|
||||
public async Task Initialize_IgnoresFutureReservedMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
|
|
@ -157,18 +259,101 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
|
||||
Meta = new JsonObject
|
||||
{
|
||||
[MetaKeys.ClientInfo] = new JsonObject
|
||||
{
|
||||
["name"] = "per-request-meta-client",
|
||||
["version"] = "1.0.0",
|
||||
},
|
||||
[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion,
|
||||
[MetaKeys.ClientInfo] = "not-an-object",
|
||||
[MetaKeys.ClientCapabilities] = "not-an-object",
|
||||
[MetaKeys.LogLevel] = "not-a-level",
|
||||
},
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct));
|
||||
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresModernProtocolVersionMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct));
|
||||
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(2),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = new JsonObject
|
||||
{
|
||||
[MetaKeys.ProtocolVersion] = new JsonObject(),
|
||||
[MetaKeys.ClientInfo] = "not-an-object",
|
||||
[MetaKeys.ClientCapabilities] = "not-an-object",
|
||||
[MetaKeys.LogLevel] = "not-a-level",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "null")]
|
||||
[InlineData(MetaKeys.ClientCapabilities, "[]")]
|
||||
[InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")]
|
||||
[InlineData(MetaKeys.LogLevel, "\"not-a-level\"")]
|
||||
public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var meta = PerRequestMetadata();
|
||||
meta[key] = JsonNode.Parse(valueJson);
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Id = new RequestId(1),
|
||||
Method = RequestMethods.ToolsList,
|
||||
Params = new JsonObject
|
||||
{
|
||||
["_meta"] = meta,
|
||||
},
|
||||
};
|
||||
|
||||
var error = Assert.IsType<JsonRpcError>(await SendAndReceiveAsync(request, ct));
|
||||
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
|
||||
Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
|
||||
Assert.Contains(key, error.Error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_server.NegotiatedProtocolVersion);
|
||||
|
||||
Assert.IsType<JsonRpcResponse>(
|
||||
await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,7 +377,6 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("initialize")]
|
||||
[InlineData("ping")]
|
||||
[InlineData("logging/setLevel")]
|
||||
[InlineData("resources/subscribe")]
|
||||
|
|
|
|||
|
|
@ -166,14 +166,14 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed()
|
||||
public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed()
|
||||
{
|
||||
// Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic
|
||||
// on the same connection. The exact mix below is what a permissive client running against an unknown
|
||||
// server would emit while probing.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}");
|
||||
// Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails.
|
||||
// This is the sequence a permissive client emits against an unknown server: the failed probe never
|
||||
// establishes a version, so initialize is still available.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}");
|
||||
var discover = await ReadAsync();
|
||||
Assert.NotNull(discover["result"]);
|
||||
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
|
||||
var init = await ReadAsync();
|
||||
|
|
@ -186,5 +186,29 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
|
|||
var call = await ReadAsync();
|
||||
Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tools/list")]
|
||||
[InlineData("server/discover")]
|
||||
public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod)
|
||||
{
|
||||
// A modern request locks the session's protocol version. initialize declares nothing on stdio, so
|
||||
// without this gate it would force the session back to a handshake version and make the modern
|
||||
// _meta on in-flight requests opaque.
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}");
|
||||
var established = await ReadAsync();
|
||||
Assert.NotNull(established["result"]);
|
||||
|
||||
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}");
|
||||
var init = await ReadAsync();
|
||||
Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue<int>());
|
||||
|
||||
// The session is still modern: a request carrying full per-request metadata keeps working.
|
||||
await SendAsync(
|
||||
@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," +
|
||||
July2026ProtocolMetaFragment() + "}}");
|
||||
var call = await ReadAsync();
|
||||
Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -123,23 +123,23 @@ public class TaskProtocolGatingTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata()
|
||||
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability()
|
||||
{
|
||||
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
|
||||
// request. The server rejects reserved per-request metadata before it can affect behavior.
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolAsTaskAsync(
|
||||
// A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The
|
||||
// metadata is tolerated, but it cannot opt the legacy session into modern-only tasks.
|
||||
var result = await client.CallToolAsTaskAsync(
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = "test-tool",
|
||||
Arguments = CreateArguments("input", "forged"),
|
||||
Meta = CreateForgedTaskOptInMeta(),
|
||||
}, ct));
|
||||
}, ct);
|
||||
|
||||
Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode);
|
||||
Assert.Contains(ClientCapabilitiesMetaKey, ex.Message);
|
||||
Assert.False(result.IsTask);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue