Skip to content

Commit 7af7aa2

Browse files
authored
Merge branch 'main' into docs/oauth-scope-selector-1867
2 parents 2fe26a4 + c40ee04 commit 7af7aa2

18 files changed

Lines changed: 964 additions & 394 deletions

‎Directory.Packages.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858

5959
<!-- Build Infra & Packaging dependencies-->
6060
<ItemGroup>
61-
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
61+
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
6262
</ItemGroup>
6363

6464
<!-- Testing dependencies -->

‎src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -846,23 +846,17 @@ private static bool ValidateProtocolVersionEnvelope(
846846
return false;
847847
}
848848

849-
bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
850-
851-
if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
852-
!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
849+
// Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve
850+
// the version from the session or metadata, falling back to legacy handling when neither selects one.
851+
if (string.IsNullOrEmpty(protocolVersionHeader) ||
852+
McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader))
853853
{
854854
errorDetail = null;
855855
return true;
856856
}
857857

858-
if (string.IsNullOrEmpty(protocolVersionHeader))
859-
{
860-
errorDetail = CreateHeaderMismatchError(
861-
$"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version.");
862-
return false;
863-
}
864-
865-
if (!hasProtocolVersionMeta)
858+
// Header validity was checked first, so only supported modern headers reach this point.
859+
if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta))
866860
{
867861
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
868862
// (a JSON-RPC error response has no recipient), and rejecting them silently drops

‎src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,33 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
9999
// behavior. Capture the underlying error (status + body) before falling back so that,
100100
// if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller
101101
// instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526).
102-
LogStreamableHttpFailed(_name, response.StatusCode);
103-
104102
// This reads the response body a second time for the application/json case, where
105103
// TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first
106104
// read, so this returns the same buffered content and is safe (not a second stream
107105
// consumption). For the common non-JSON error responses (415, 405, plain text)
108106
// TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read.
109107
var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
110108

109+
if (IsDiscoverProbeRejection(message, response.StatusCode))
110+
{
111+
// The server/discover probe is protocol negotiation, not transport detection. A server
112+
// predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or
113+
// 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable
114+
// HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl
115+
// .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and
116+
// immediately retries with initialize on this same transport — and that attempt still
117+
// falls back to SSE, so an SSE-only server is reached one POST later rather than not at
118+
// all. Attempting SSE here instead spends a GET whose result is discarded on every
119+
// connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling
120+
// back to SSE transport" line that misreports settled protocol negotiation as a failure.
121+
LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode);
122+
123+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
124+
throw streamableHttpError;
125+
}
126+
127+
LogStreamableHttpFailed(_name, response.StatusCode);
128+
111129
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
112130
await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false);
113131
}
@@ -122,6 +140,15 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
122140
}
123141
}
124142

143+
/// <summary>
144+
/// Returns <see langword="true"/> when the failed request was the SEP-2575 <c>server/discover</c> probe and the
145+
/// status is one of the two <see cref="McpClientImpl"/> already reads as "this server requires the initialize
146+
/// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't.
147+
/// </summary>
148+
private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) =>
149+
statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound &&
150+
message is JsonRpcRequest { Method: RequestMethods.ServerDiscover };
151+
125152
private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken)
126153
{
127154
if (_options.KnownSessionId is not null)
@@ -184,6 +211,9 @@ public async ValueTask DisposeAsync()
184211
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
185212
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
186213

214+
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")]
215+
private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode);
216+
187217
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
188218
private partial void LogUsingStreamableHttp(string endpointName);
189219

‎src/ModelContextProtocol.Core/McpSessionHandler.cs‎

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -401,14 +401,6 @@ private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Tas
401401

402402
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
403403
{
404-
// Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any
405-
// filters run so they (and downstream handlers) can read client info / capabilities /
406-
// protocol version / log level without re-parsing.
407-
if (_isServer && message is JsonRpcRequest incomingRequest)
408-
{
409-
PopulateContextFromMeta(incomingRequest);
410-
}
411-
412404
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
413405
string method = GetMethodName(message);
414406

@@ -544,65 +536,6 @@ await SendMessageAsync(new JsonRpcResponse
544536
return result;
545537
}
546538

547-
/// <summary>
548-
/// Reads the 2026-07-28 protocol's per-request <c>_meta</c> fields off the request and projects them onto
549-
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
550-
/// </summary>
551-
/// <remarks>
552-
/// Per SEP-2575 the keys are <c>io.modelcontextprotocol/protocolVersion</c>,
553-
/// <c>/clientInfo</c>, <c>/clientCapabilities</c>, and (optional) <c>/logLevel</c>. Any field
554-
/// that's already set on the context (e.g., <see cref="JsonRpcMessageContext.ProtocolVersion"/>
555-
/// populated by the HTTP transport from the <c>MCP-Protocol-Version</c> header) is left alone
556-
/// unless explicitly overwritten by a non-null value parsed here.
557-
/// </remarks>
558-
internal static void PopulateContextFromMeta(JsonRpcRequest request)
559-
{
560-
if (request.Params is not JsonObject paramsObj)
561-
{
562-
return;
563-
}
564-
565-
if (paramsObj["_meta"] is not JsonObject metaObj)
566-
{
567-
return;
568-
}
569-
570-
var context = request.Context ??= new JsonRpcMessageContext();
571-
572-
if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion &&
573-
protocolVersion.TryGetValue(out string? protocolVersionValue))
574-
{
575-
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already
576-
// populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
577-
// -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
578-
// which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather
579-
// than mistaking it for an initialize-handshake server and falling back to initialize.
580-
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
581-
{
582-
throw new McpProtocolException(
583-
$"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.",
584-
McpErrorCode.HeaderMismatch);
585-
}
586-
587-
context.ProtocolVersion = protocolVersionValue;
588-
}
589-
590-
if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode)
591-
{
592-
context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation);
593-
}
594-
595-
if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode)
596-
{
597-
context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities);
598-
}
599-
600-
if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode)
601-
{
602-
context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel);
603-
}
604-
}
605-
606539
/// <summary>
607540
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields into an outgoing request.
608541
/// Protocol version and client info overwrite any existing values; client capabilities are merged

‎src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext
8888
public string? RoutingName { get; set; }
8989

9090
/// <summary>
91-
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
92-
/// that accompanied this JSON-RPC message.
91+
/// Gets or sets the authoritative protocol version for this JSON-RPC message.
9392
/// </summary>
9493
/// <remarks>
95-
/// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the <c>initialize</c>
96-
/// handshake because each request creates a new server instance. This property allows the transport layer
97-
/// to flow the protocol version header so the server can determine client capabilities.
94+
/// The transport may populate this from a header such as <c>Mcp-Protocol-Version</c>. For modern revisions,
95+
/// the server validates and projects the matching per-request <c>_meta</c> value. Under an established legacy
96+
/// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version.
9897
/// </remarks>
9998
public string? ProtocolVersion { get; set; }
10099

@@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext
104103
/// </summary>
105104
/// <remarks>
106105
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision,
107-
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake.
106+
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake. Future reserved
107+
/// metadata remains opaque under legacy revisions, which continue to use their initialized identity.
108108
/// </remarks>
109109
public Implementation? ClientInfo { get; set; }
110110

@@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext
114114
/// </summary>
115115
/// <remarks>
116116
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client
117-
/// capabilities from previous requests; the authoritative value is the one declared on each request.
117+
/// capabilities from previous modern requests; the authoritative value is the one declared on each request.
118+
/// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities
119+
/// negotiated during initialization.
118120
/// </remarks>
119121
public ClientCapabilities? ClientCapabilities { get; set; }
120122

@@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext
124126
/// </summary>
125127
/// <remarks>
126128
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy
127-
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent, the server MUST NOT emit log notifications
128-
/// for the request.
129+
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent from a modern request, the server MUST NOT emit
130+
/// log notifications for the request. Legacy requests continue to use their negotiated logging behavior.
129131
/// </remarks>
130132
public LoggingLevel? LogLevel { get; set; }
131133
}

‎src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,6 @@ public override ClientCapabilities? ClientCapabilities
2323
{
2424
get
2525
{
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-
3426
// On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request)
3527
// and must not be inferred from prior requests. Missing per-request capabilities therefore means
3628
// "no declared capabilities for this request", represented by an empty object. A fresh instance is
@@ -42,11 +34,13 @@ public override ClientCapabilities? ClientCapabilities
4234
}
4335

4436
// 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.
37+
// pre-populated migration data). Future reserved metadata remains opaque under legacy semantics.
4638
return server.ClientCapabilities;
4739
}
4840
}
4941

42+
internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests;
43+
5044
public override Implementation? ClientInfo
5145
{
5246
get

‎src/ModelContextProtocol.Core/Server/McpServer.Methods.cs‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession
2525

2626
internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
2727

28+
internal virtual bool SupportsServerToClientRequests => true;
29+
2830
/// <summary>
2931
/// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
3032
/// </summary>
@@ -555,26 +557,26 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty
555557

556558
private void ThrowIfSamplingUnsupported()
557559
{
558-
if (ClientCapabilities?.Sampling is null)
560+
if (!SupportsServerToClientRequests)
559561
{
560-
if (ClientCapabilities is null)
561-
{
562-
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
563-
}
562+
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
563+
}
564564

565+
if (ClientCapabilities?.Sampling is null)
566+
{
565567
throw new InvalidOperationException("Client does not support sampling.");
566568
}
567569
}
568570

569571
private void ThrowIfRootsUnsupported()
570572
{
571-
if (ClientCapabilities?.Roots is null)
573+
if (!SupportsServerToClientRequests)
572574
{
573-
if (ClientCapabilities is null)
574-
{
575-
throw new InvalidOperationException("Roots are not supported in stateless mode.");
576-
}
575+
throw new InvalidOperationException("Roots are not supported in stateless mode.");
576+
}
577577

578+
if (ClientCapabilities?.Roots is null)
579+
{
578580
throw new InvalidOperationException("Client does not support roots.");
579581
}
580582
}
@@ -615,12 +617,12 @@ private async ValueTask<TResponse> SendRequestViaInterceptorAsync<TRequest, TRes
615617

616618
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
617619
{
618-
if (ClientCapabilities is null)
620+
if (!SupportsServerToClientRequests)
619621
{
620622
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
621623
}
622624

623-
var elicitationCapability = ClientCapabilities.Elicitation;
625+
var elicitationCapability = ClientCapabilities?.Elicitation;
624626
if (elicitationCapability is null)
625627
{
626628
throw new InvalidOperationException("Client does not support elicitation requests.");

‎src/ModelContextProtocol.Core/Server/McpServer.cs‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,11 @@ protected McpServer()
3232
/// the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler; on the
3333
/// root <see cref="McpServer"/> (for example one constructed manually over a
3434
/// <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.
3735
/// </para>
3836
/// <para>
39-
/// Server implementations can check these capabilities to determine which features
40-
/// are available when interacting with the client.
37+
/// This property reports capabilities declared by the client. Their presence does not guarantee that the
38+
/// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject
39+
/// those requests when the transport cannot safely deliver them, including in stateless mode.
4140
/// </para>
4241
/// </remarks>
4342
public abstract ClientCapabilities? ClientCapabilities { get; }

0 commit comments

Comments
 (0)