Skip to content

Commit a8d374b

Browse files
PranavSenthilnathanCopilotTarek Mahmoud Sayed
authored
Fix request-scoped draft client capabilities (modelcontextprotocol#1685)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent b2a4012 commit a8d374b

5 files changed

Lines changed: 306 additions & 40 deletions

File tree

src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,66 @@
55
namespace ModelContextProtocol.Server;
66

77
#pragma warning disable MCPEXP002
8-
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer
8+
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer
99
#pragma warning restore MCPEXP002
1010
{
11+
private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext);
12+
private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities;
13+
private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo;
14+
1115
public override string? SessionId => transport?.SessionId ?? server.SessionId;
1216
public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion;
13-
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
14-
public override Implementation? ClientInfo => server.ClientInfo;
1517
public override McpServerOptions ServerOptions => server.ServerOptions;
1618
public override IServiceProvider? Services => server.Services;
1719
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
1820
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
1921

22+
public override ClientCapabilities? ClientCapabilities
23+
{
24+
get
25+
{
26+
// In stateless transport mode, a single request does not have a persistent bidirectional channel.
27+
// Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the
28+
// capability gates rely on a null ClientCapabilities value to report that unsupported-state path.
29+
if (!server.HasStatefulTransport())
30+
{
31+
return null;
32+
}
33+
34+
// On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request)
35+
// and must not be inferred from prior requests. Missing per-request capabilities therefore means
36+
// "no declared capabilities for this request", represented by an empty object. A fresh instance is
37+
// returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared
38+
// static empty instance could be mutated and leak across requests.
39+
if (_isJuly2026OrLaterRequest)
40+
{
41+
return _requestClientCapabilities ?? new ClientCapabilities();
42+
}
43+
44+
// Legacy protocol behavior uses session-scoped capabilities established during initialize (or
45+
// pre-populated migration data), so ignore per-request values and return the server session state.
46+
return server.ClientCapabilities;
47+
}
48+
}
49+
50+
public override Implementation? ClientInfo
51+
{
52+
get
53+
{
54+
// On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta),
55+
// mirroring how ClientCapabilities is resolved above. Return only this request's declared value and
56+
// do not fall back to shared session state, which under a stateful transport could belong to a
57+
// different concurrent request.
58+
if (_isJuly2026OrLaterRequest)
59+
{
60+
return _requestClientInfo;
61+
}
62+
63+
// Legacy protocol behavior uses session-scoped client info established during initialize.
64+
return server.ClientInfo;
65+
}
66+
}
67+
2068
/// <summary>
2169
/// Gets or sets the MRTR context for the current request, if any.
2270
/// Set by <see cref="McpServerImpl.CreateDestinationBoundServer"/> when an MRTR-aware handler invocation is in progress.

src/ModelContextProtocol.Core/Server/McpServer.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,19 @@ protected McpServer()
2121
/// </summary>
2222
/// <remarks>
2323
/// <para>
24-
/// These capabilities are established during the initialization handshake and indicate
25-
/// which features the client supports, such as sampling, roots, and other
26-
/// protocol-specific functionality.
24+
/// On protocol revisions that use the <c>initialize</c> handshake (<c>2025-11-25</c> and earlier), these
25+
/// capabilities are established once during initialization and are session-scoped: they are available both
26+
/// on the root <see cref="McpServer"/> and on the server exposed to request handlers.
27+
/// </para>
28+
/// <para>
29+
/// On the <c>2026-07-28</c> revision and later (SEP-2575) there is no <c>initialize</c> handshake; the client
30+
/// declares its capabilities per-request in <c>_meta</c>, and the server MUST NOT infer them from previous
31+
/// requests. In that mode this property is only meaningful on the request-scoped server accessed via
32+
/// the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler; on the
33+
/// root <see cref="McpServer"/> (for example one constructed manually over a
34+
/// <see cref="System.IO.Stream"/>) it is <see langword="null"/>.
35+
/// It is also <see langword="null"/> in stateless transport mode, where server-to-client requests are
36+
/// unsupported.
2737
/// </para>
2838
/// <para>
2939
/// Server implementations can check these capabilities to determine which features
@@ -38,7 +48,14 @@ protected McpServer()
3848
/// <remarks>
3949
/// <para>
4050
/// This property contains identification information about the client that has connected to this server,
41-
/// including its name and version. This information is provided by the client during initialization.
51+
/// including its name and version.
52+
/// </para>
53+
/// <para>
54+
/// On protocol revisions that use the <c>initialize</c> handshake (<c>2025-11-25</c> and earlier) this
55+
/// information is provided once during initialization and is session-scoped. On the <c>2026-07-28</c>
56+
/// revision and later it is carried per-request in <c>_meta</c>, so read it from the request-scoped server
57+
/// accessed via the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler
58+
/// rather than from the root <see cref="McpServer"/>.
4259
/// </para>
4360
/// <para>
4461
/// Server implementations can use this information for logging, tracking client versions,

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,17 @@ void Register<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? collection,
152152

153153
/// <summary>
154154
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
155-
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>,
156-
/// <see cref="_clientCapabilities"/>, <see cref="_clientInfo"/>) from the per-request <c>_meta</c>
157-
/// values projected onto <see cref="JsonRpcMessageContext"/> and validates the per-request protocol
158-
/// version, before delegating to the user-supplied incoming filters.
155+
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>)
156+
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
157+
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
159158
/// </summary>
160159
/// <remarks>
161160
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> handshake, so these values
162-
/// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in
163-
/// filter is a no-op (the values were captured during the initialize handler).
161+
/// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped
162+
/// by <see cref="DestinationBoundMcpServer"/> and are not read from server-wide state by request handlers. The
163+
/// shared <see cref="_clientInfo"/> write below is best-effort and used only to derive the session endpoint
164+
/// name for logging/telemetry. For legacy clients the per-request values are absent and the built-in filter is
165+
/// a no-op (the values were captured during the initialize handler).
164166
/// </remarks>
165167
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
166168
{
@@ -185,23 +187,16 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner
185187
SetNegotiatedProtocolVersion(protocolVersion);
186188
}
187189

188-
if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport())
189-
{
190-
// Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL
191-
// capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate
192-
// makes any legacy per-request envelope a no-op (legacy capabilities stay as the
193-
// initialize handshake established them); the HasStatefulTransport() gate keeps
194-
// _clientCapabilities null under StreamableHttpServerTransport { Stateless = true }
195-
// (where the same server instance handles every request, so persisting per-request
196-
// capability state would both leak across requests and break the StatelessServerTests
197-
// invariant that surfaces the "X is not supported in stateless mode" errors).
198-
_clientCapabilities = clientCapabilities;
199-
}
200-
201190
if (context.ClientInfo is { } clientInfo &&
202191
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
203192
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
204193
{
194+
// This shared write is best-effort and used only to derive the session endpoint name for
195+
// logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions:
196+
// DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from
197+
// the per-request _meta so concurrent requests never observe each other's values. Under a
198+
// draft stateful session with differing per-request client info, the last writer wins here,
199+
// which only affects the logged endpoint name and never the request-scoped values handlers see.
205200
_clientInfo = clientInfo;
206201
endpointNameNeedsRefresh = true;
207202
}
@@ -1627,7 +1622,7 @@ async ValueTask<TResult> InvokeScopedAsync(
16271622

16281623
private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest)
16291624
{
1630-
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport);
1625+
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context);
16311626

16321627
if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
16331628
{
@@ -1740,7 +1735,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter>
17401735
{
17411736
// Ensure message has a Context so Items can be shared through the pipeline
17421737
message.Context ??= new();
1743-
var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message);
1738+
var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message);
17441739
await current(context, cancellationToken).ConfigureAwait(false);
17451740
};
17461741
};
@@ -1795,8 +1790,12 @@ internal bool HasStatefulTransport() =>
17951790
/// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
17961791
/// </summary>
17971792
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
1793+
IsJuly2026OrLaterProtocolRequest(request?.Context);
1794+
1795+
/// <inheritdoc cref="IsJuly2026OrLaterProtocolRequest(JsonRpcRequest?)"/>
1796+
internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) =>
17981797
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
1799-
request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion);
1798+
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);
18001799

18011800
/// <inheritdoc />
18021801
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();

0 commit comments

Comments
 (0)