From 1193b7bf2e4a3a3644366f5d357d555eff4db87f Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 30 Jun 2026 18:17:26 -0700 Subject: [PATCH 1/4] Fix request-scoped draft client capabilities Route client capability resolution through request-scoped DestinationBoundMcpServer state for 2026-07-28+ requests, stop persisting per-request capabilities into McpServerImpl global state, and add a concurrency regression test that verifies overlapping requests observe their own declared capabilities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/DestinationBoundMcpServer.cs | 39 ++++++++- .../Server/McpServerImpl.cs | 30 ++----- .../Client/McpClientMetaTests.cs | 82 +++++++++++++++++++ 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..5e085b139 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,18 +5,47 @@ 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 Implementation? ClientInfo => _requestClientInfo ?? 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; + } + } + /// /// 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 +119,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..35a575ea1 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,85 @@ 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 ResourceReadWithMetaFields() { From 6b5e5ae01c7362d052b487900c145985fd7b81f4 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 30 Jun 2026 18:42:10 -0700 Subject: [PATCH 2/4] Make request-scoped ClientInfo consistent with ClientCapabilities Gate DestinationBoundMcpServer.ClientInfo on the 2026-07-28+ protocol like ClientCapabilities so request handlers observe this request's declared client info rather than falling back to shared session state, which under a stateful transport could belong to a different concurrent request. Add a regression test verifying a tool observes the per-request client info. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/DestinationBoundMcpServer.cs | 19 ++++++++++++- .../Client/McpClientMetaTests.cs | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 5e085b139..f75f547e8 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -14,7 +14,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override Implementation? ClientInfo => _requestClientInfo ?? 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)] @@ -46,6 +45,24 @@ public override ClientCapabilities? 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. diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 35a575ea1..af2043d0e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -198,6 +198,34 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques 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() { From 878e00290fc082f77137c15f56177f1e472c9389 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 10 Jul 2026 16:45:49 -0700 Subject: [PATCH 3/4] Resolve request-scoped client capabilities in outgoing filters and add coverage Source per-request client capabilities/info from JsonRpcMessageContext so outgoing message filters (built from responses) observe them, not just request handlers. Document that the shared client-info sync is best-effort for endpoint-name logging only, and clarify request-scoped semantics on McpServer.ClientCapabilities/ClientInfo. Pin UrlElicitationTests to the 2025-11-25 handshake revision for root-server capability assertions and add draft tests for request-scoped and outgoing-filter observation. --- .../Server/DestinationBoundMcpServer.cs | 12 +-- .../Server/McpServer.cs | 25 +++++- .../Server/McpServerImpl.cs | 19 ++-- .../Client/McpClientMetaTests.cs | 90 ++++++++++++++++++- .../Protocol/UrlElicitationTests.cs | 30 ++++--- 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index f75f547e8..c54ccbb50 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,12 +5,12 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcRequest? jsonRpcRequest = null) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = 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; + private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(requestContext, server.NegotiatedProtocolVersion); + private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; @@ -137,7 +137,7 @@ private static async Task SendRequestViaMrtrAsync( }; } - private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request, string? negotiatedProtocolVersion) + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext, string? negotiatedProtocolVersion) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? negotiatedProtocolVersion); + requestContext?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 5f8ebf69a..a51010dc0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -21,9 +21,19 @@ protected McpServer() /// /// /// - /// These capabilities are established during the initialization handshake and indicate - /// which features the client supports, such as sampling, roots, and other - /// protocol-specific functionality. + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these + /// capabilities are established once during initialization and are session-scoped: they are available both + /// on the root and on the server exposed to request handlers. + /// + /// + /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client + /// declares its capabilities per-request in _meta, and the server MUST NOT infer them from previous + /// requests. In that mode this property is only meaningful on the request-scoped server accessed via + /// the Server property of the passed to a handler; on the + /// root (for example one constructed manually over a + /// ) it is . + /// It is also in stateless transport mode, where server-to-client requests are + /// unsupported. /// /// /// Server implementations can check these capabilities to determine which features @@ -38,7 +48,14 @@ protected McpServer() /// /// /// This property contains identification information about the client that has connected to this server, - /// including its name and version. This information is provided by the client during initialization. + /// including its name and version. + /// + /// + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier) this + /// information is provided once during initialization and is session-scoped. On the 2026-07-28 + /// revision and later it is carried per-request in _meta, so read it from the request-scoped server + /// accessed via the Server property of the passed to a handler + /// rather than from the root . /// /// /// Server implementations can use this information for logging, tracking client versions, diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 05a4e676c..8576a59c9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -158,10 +158,11 @@ void Register(McpServerPrimitiveCollection? collection, /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// 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). + /// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped + /// by and are not read from server-wide state by request handlers. The + /// shared write below is best-effort and used only to derive the session endpoint + /// name for logging/telemetry. 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) { @@ -190,6 +191,12 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { + // This shared write is best-effort and used only to derive the session endpoint name for + // logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions: + // DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from + // the per-request _meta so concurrent requests never observe each other's values. Under a + // draft stateful session with differing per-request client info, the last writer wins here, + // which only affects the logged endpoint name and never the request-scoped values handlers see. _clientInfo = clientInfo; endpointNameNeedsRefresh = true; } @@ -1615,7 +1622,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1728,7 +1735,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 as JsonRpcRequest), message); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index af2043d0e..3afbb52eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -16,7 +16,8 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); - private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) @@ -42,6 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Capture the _meta the server receives on the initialize request so tests can // assert that McpClientOptions.InitializeMeta is threaded through the handshake. mcpServerBuilder.WithMessageFilters(filters => + { filters.AddIncomingFilter(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) @@ -50,7 +52,23 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } await next(context, cancellationToken); - })); + }); + + // Capture the request-scoped client info/capabilities observed while an outgoing response flows + // through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test + // triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for + // responses (whose Context is the originating request's Context), not just requests. + filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse && + context.Server.ClientInfo is { Name: "outgoing-filter-client" } info) + { + _outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities)); + } + + await next(context, cancellationToken); + }); + }); } [Fact] @@ -161,7 +179,7 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques }, Meta = new JsonObject { - [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( new ClientCapabilities { Sampling = new SamplingCapability() }, McpJsonUtilities.DefaultOptions), }, @@ -176,7 +194,7 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques }, Meta = new JsonObject { - [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( new ClientCapabilities(), McpJsonUtilities.DefaultOptions), }, @@ -226,6 +244,70 @@ public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo Assert.Equal("request-scoped-client:9.9.9", text); } + [Fact] + public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem() + { + ClientCapabilities? handlerObservedCapabilities = null; + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + handlerObservedCapabilities = context.Server.ClientCapabilities; + return "ok"; + }, + new() { Name = "capability_probe_tool" })); + + var clientOptions = new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + // Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any + // request) never exposes them, whereas a request handler observes the per-request _meta values. + Assert.Null(Server.ClientCapabilities); + + await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(handlerObservedCapabilities); + Assert.NotNull(handlerObservedCapabilities!.Elicitation); + Assert.Null(Server.ClientCapabilities); + } + + [Fact] + public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + () => "ok", + new() { Name = "outgoing_probe_tool" })); + + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + var (info, capabilities) = await _outgoingFilterObserved.Task + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.NotNull(info); + Assert.Equal("outgoing-filter-client", info!.Name); + Assert.Equal("3.2.1", info.Version); + Assert.NotNull(capabilities); + Assert.NotNull(capabilities!.Elicitation); + } + [Fact] public async Task ResourceReadWithMetaFields() { diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index bf4c67d21..4963a0a10 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -190,6 +190,16 @@ await request.Server.ElicitAsync(new() }); } + // These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability), + // which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision + // so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped + // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. + private Task CreateLegacyClientForServer(McpClientOptions clientOptions) + { + clientOptions.ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + return CreateMcpClientForServer(clientOptions); + } + [Fact] public async Task Can_Elicit_OutOfBand_With_Url() { @@ -198,7 +208,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() string? capturedMessage = null; var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -283,7 +293,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() [Fact] public async Task UrlElicitation_User_Can_Decline() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -322,7 +332,7 @@ public async Task UrlElicitation_User_Can_Decline() [Fact] public async Task UrlElicitation_User_Can_Cancel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -360,7 +370,7 @@ public async Task UrlElicitation_User_Can_Cancel() [Fact] public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -385,7 +395,7 @@ public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() [Fact] public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -406,7 +416,7 @@ public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() [Fact] public async Task UrlElicitation_BlankCapability_Allows_Only_Form() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -435,7 +445,7 @@ public async Task UrlElicitation_BlankCapability_Allows_Only_Form() [Fact] public async Task FormElicitation_UrlOnlyCapability_NotSupported() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -474,7 +484,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -504,7 +514,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() [Fact] public async Task UrlElicitationRequired_Exception_Propagates_To_Client() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -532,7 +542,7 @@ public async Task FormElicitation_Requires_RequestedSchema() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { From c0e58e86c06d619df8b822b25f967523c257dc8d Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 10 Jul 2026 17:03:28 -0700 Subject: [PATCH 4/4] Consolidate July2026 protocol helper and document empty-capabilities fallback Remove the duplicate IsJuly2026OrLaterProtocolRequest in DestinationBoundMcpServer and route through a single internal overload on McpServerImpl. Clarify why the request-scoped ClientCapabilities fallback returns a fresh instance rather than a shared mutable singleton. --- .../Server/DestinationBoundMcpServer.cs | 10 ++++------ src/ModelContextProtocol.Core/Server/McpServerImpl.cs | 6 +++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index c54ccbb50..7aab34826 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Server; internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer #pragma warning restore MCPEXP002 { - private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(requestContext, server.NegotiatedProtocolVersion); + private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext); private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; @@ -33,7 +33,9 @@ public override ClientCapabilities? ClientCapabilities // 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. + // "no declared capabilities for this request", represented by an empty object. A fresh instance is + // returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared + // static empty instance could be mutated and leak across requests. if (_isJuly2026OrLaterRequest) { return _requestClientCapabilities ?? new ClientCapabilities(); @@ -136,8 +138,4 @@ private static async Task SendRequestViaMrtrAsync( Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), }; } - - private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext, string? negotiatedProtocolVersion) - => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - requestContext?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8576a59c9..d98a6c7c8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1790,8 +1790,12 @@ internal bool HasStatefulTransport() => /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + IsJuly2026OrLaterProtocolRequest(request?.Context); + + /// + internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); + requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();