Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +8 to +13

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;
}
}

/// <summary>
/// Gets or sets the MRTR context for the current request, if any.
/// Set by <see cref="McpServerImpl.CreateDestinationBoundServer"/> when an MRTR-aware handler invocation is in progress.
Expand Down Expand Up @@ -90,4 +136,8 @@ private static async Task<JsonRpcResponse> SendRequestViaMrtrAsync(
Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement),
};
}

private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request, string? negotiatedProtocolVersion)
=> McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
request?.Context?.ProtocolVersion ?? negotiatedProtocolVersion);
}
30 changes: 9 additions & 21 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,16 @@ void Register<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? collection,

/// <summary>
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>,
/// <see cref="_clientCapabilities"/>, <see cref="_clientInfo"/>) from the per-request <c>_meta</c>
/// values projected onto <see cref="JsonRpcMessageContext"/> and validates the per-request protocol
/// version, before delegating to the user-supplied incoming filters.
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>)
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
/// </summary>
/// <remarks>
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> 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
/// <see cref="DestinationBoundMcpServer"/> 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).
/// </remarks>
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
{
Expand All @@ -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)))
Expand Down Expand Up @@ -1627,7 +1615,7 @@ async ValueTask<TResult> 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))
{
Expand Down Expand Up @@ -1740,7 +1728,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter>
{
// 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);
};
};
Expand Down
110 changes: 110 additions & 0 deletions tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +16,8 @@ public class McpClientMetaTests : ClientServerTestBase

private readonly TaskCompletionSource<JsonNode?> _initializeMeta = new();

private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";

public McpClientMetaTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
Expand Down Expand Up @@ -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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var withoutSamplingReady = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var allowSamplingChecks = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
async (string requestId, RequestContext<CallToolRequestParams> 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<string, JsonElement>
{
["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<string, JsonElement>
{
["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(),
},
Meta = new JsonObject
{
[ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode(
new ClientCapabilities(),
McpJsonUtilities.DefaultOptions),
},
};

Task<CallToolResult> withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask();
Task<CallToolResult> 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<TextContentBlock>(Assert.Single(withSamplingResult.Content)).Text;
var withoutSamplingText = Assert.IsType<TextContentBlock>(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<CallToolRequestParams> 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<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal("request-scoped-client:9.9.9", text);
}

[Fact]
public async Task ResourceReadWithMetaFields()
{
Expand Down
Loading