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
54 changes: 51 additions & 3 deletions src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,66 @@
namespace ModelContextProtocol.Server;

#pragma warning disable MCPEXP002
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer
internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer
#pragma warning restore MCPEXP002
{
private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext);
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;
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. 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();
}

// 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
25 changes: 21 additions & 4 deletions src/ModelContextProtocol.Core/Server/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ protected McpServer()
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>initialize</c> handshake (<c>2025-11-25</c> and earlier), these
/// capabilities are established once during initialization and are session-scoped: they are available both
/// on the root <see cref="McpServer"/> and on the server exposed to request handlers.
/// </para>
/// <para>
/// On the <c>2026-07-28</c> revision and later (SEP-2575) there is no <c>initialize</c> handshake; the client
/// declares its capabilities per-request in <c>_meta</c>, 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 <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler; on the
/// root <see cref="McpServer"/> (for example one constructed manually over a
/// <see cref="System.IO.Stream"/>) it is <see langword="null"/>.
/// It is also <see langword="null"/> in stateless transport mode, where server-to-client requests are
/// unsupported.
/// </para>
/// <para>
/// Server implementations can check these capabilities to determine which features
Expand All @@ -38,7 +48,14 @@ protected McpServer()
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// On protocol revisions that use the <c>initialize</c> handshake (<c>2025-11-25</c> and earlier) this
/// information is provided once during initialization and is session-scoped. On the <c>2026-07-28</c>
/// revision and later it is carried per-request in <c>_meta</c>, so read it from the request-scoped server
/// accessed via the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler
/// rather than from the root <see cref="McpServer"/>.
/// </para>
/// <para>
/// Server implementations can use this information for logging, tracking client versions,
Expand Down
43 changes: 21 additions & 22 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,17 @@ 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 and client info are consumed request-scoped
/// by <see cref="DestinationBoundMcpServer"/> and are not read from server-wide state by request handlers. The
/// shared <see cref="_clientInfo"/> 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).
/// </remarks>
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
{
Expand All @@ -185,23 +187,16 @@ 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)))
{
// 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;
}
Expand Down Expand Up @@ -1627,7 +1622,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.Context);

if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
{
Expand Down Expand Up @@ -1740,7 +1735,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.Context), message);
await current(context, cancellationToken).ConfigureAwait(false);
};
};
Expand Down Expand Up @@ -1795,8 +1790,12 @@ internal bool HasStatefulTransport() =>
/// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
/// </summary>
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
IsJuly2026OrLaterProtocolRequest(request?.Context);

/// <inheritdoc cref="IsJuly2026OrLaterProtocolRequest(JsonRpcRequest?)"/>
internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion);
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);

/// <inheritdoc />
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();
Expand Down
Loading