diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..f75f547e8 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,18 +5,64 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcRequest? jsonRpcRequest = null) : McpServer #pragma warning restore MCPEXP002 { + private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(jsonRpcRequest, server.NegotiatedProtocolVersion); + private readonly ClientCapabilities? _requestClientCapabilities = jsonRpcRequest?.Context?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = jsonRpcRequest?.Context?.ClientInfo; + public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; - public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => server.LoggingLevel; + public override ClientCapabilities? ClientCapabilities + { + get + { + // In stateless transport mode, a single request does not have a persistent bidirectional channel. + // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the + // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. + if (!server.HasStatefulTransport()) + { + return null; + } + + // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) + // and must not be inferred from prior requests. Missing per-request capabilities therefore means + // "no declared capabilities for this request", represented by an empty object. + if (_isJuly2026OrLaterRequest) + { + return _requestClientCapabilities ?? new ClientCapabilities(); + } + + // Legacy protocol behavior uses session-scoped capabilities established during initialize (or + // pre-populated migration data), so ignore per-request values and return the server session state. + return server.ClientCapabilities; + } + } + + public override Implementation? ClientInfo + { + get + { + // On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta), + // mirroring how ClientCapabilities is resolved above. Return only this request's declared value and + // do not fall back to shared session state, which under a stateful transport could belong to a + // different concurrent request. + if (_isJuly2026OrLaterRequest) + { + return _requestClientInfo; + } + + // Legacy protocol behavior uses session-scoped client info established during initialize. + return server.ClientInfo; + } + } + /// /// Gets or sets the MRTR context for the current request, if any. /// Set by when an MRTR-aware handler invocation is in progress. @@ -90,4 +136,8 @@ private static async Task SendRequestViaMrtrAsync( Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), }; } + + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request, string? negotiatedProtocolVersion) + => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( + request?.Context?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..05a4e676c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -152,15 +152,16 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, - /// , ) from the per-request _meta - /// values projected onto and validates the per-request protocol - /// version, before delegating to the user-supplied incoming filters. + /// synchronizes server-side state (, ) + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in - /// filter is a no-op (the values were captured during the initialize handler). + /// MUST be populated per-request. Per-request client capabilities are consumed request-scoped by + /// and are not persisted to server-wide state. For legacy clients + /// the per-request values are absent and the built-in filter is a no-op (the values were captured during + /// the initialize handler). /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { @@ -185,19 +186,6 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner SetNegotiatedProtocolVersion(protocolVersion); } - if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) - { - // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL - // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate - // makes any legacy per-request envelope a no-op (legacy capabilities stay as the - // initialize handshake established them); the HasStatefulTransport() gate keeps - // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } - // (where the same server instance handles every request, so persisting per-request - // capability state would both leak across requests and break the StatelessServerTests - // invariant that surfaces the "X is not supported in stateless mode" errors). - _clientCapabilities = clientCapabilities; - } - if (context.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) @@ -1627,7 +1615,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1740,7 +1728,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList { // Ensure message has a Context so Items can be shared through the pipeline message.Context ??= new(); - var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message as JsonRpcRequest), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e1e9a08db..af2043d0e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,6 +16,8 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); + private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -116,6 +119,113 @@ public async Task ToolCallWithMetaFields() Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() + { + var withSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withoutSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowSamplingChecks = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (string requestId, RequestContext context, CancellationToken cancellationToken) => + { + if (requestId == "with") + { + withSamplingReady.TrySetResult(true); + } + else if (requestId == "without") + { + withoutSamplingReady.TrySetResult(true); + } + else + { + throw new ArgumentException($"Unexpected request id '{requestId}'."); + } + + await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + + return context.Server.ClientCapabilities?.Sampling is null ? + $"{requestId}:sampling-absent" : + $"{requestId}:sampling-present"; + }, + new() { Name = "meta_sampling_tool" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var withSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }; + + var withoutSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + new ClientCapabilities(), + McpJsonUtilities.DefaultOptions), + }, + }; + + Task withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + Task withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + + await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + allowSamplingChecks.TrySetResult(true); + + CallToolResult withSamplingResult = await withSamplingTask; + CallToolResult withoutSamplingResult = await withoutSamplingTask; + + var withSamplingText = Assert.IsType(Assert.Single(withSamplingResult.Content)).Text; + var withoutSamplingText = Assert.IsType(Assert.Single(withoutSamplingResult.Content)).Text; + + Assert.Equal("with:sampling-present", withSamplingText); + Assert.Equal("without:sampling-absent", withoutSamplingText); + } + + [Fact] + public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + var clientInfo = context.Server.ClientInfo; + return clientInfo is null ? + "client-info-absent" : + $"{clientInfo.Name}:{clientInfo.Version}"; + }, + new() { Name = "client_info_tool" })); + + // The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe + // the per-request value resolved by DestinationBoundMcpServer rather than server-only session state. + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("request-scoped-client:9.9.9", text); + } + [Fact] public async Task ResourceReadWithMetaFields() {