Skip to content

Commit 26f60ab

Browse files
pcarletonhalter73Copilottarekgh
authored
Align the 2026-07-28 wire with spec PR #3002 and SEP-2575 HTTP statuses (#1752)
Co-authored-by: Stephen Halter <halter73@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: tarekgh <tarek.mahmoud.sayed@gmail.com>
1 parent 6787c0c commit 26f60ab

31 files changed

Lines changed: 894 additions & 101 deletions

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ internal sealed class StreamableHttpHandler(
5555

5656
public async Task HandlePostRequestAsync(HttpContext context)
5757
{
58-
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
59-
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
60-
{
61-
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
62-
return;
63-
}
64-
6558
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
6659
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
6760
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
@@ -104,6 +97,15 @@ await WriteJsonRpcErrorAsync(context,
10497
// Notifications carry no id, so this stays default (null), which is correct.
10598
var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default;
10699

100+
// Validated after the body parse (rather than first) so the rejection can echo the request's
101+
// JSON-RPC id: every error response for a parseable request MUST carry its id.
102+
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
103+
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
104+
{
105+
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId);
106+
return;
107+
}
108+
107109
if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError))
108110
{
109111
await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId);
@@ -116,6 +118,33 @@ await WriteJsonRpcErrorAsync(context,
116118
return;
117119
}
118120

121+
if (!ValidateRequiredPerRequestMeta(context, message, out var requiredMetaError))
122+
{
123+
await WriteJsonRpcErrorDetailAsync(context, requiredMetaError, StatusCodes.Status400BadRequest, requestId);
124+
return;
125+
}
126+
127+
// SEP-2575 removed these RPCs from the per-request-metadata HTTP surface (initialize and
128+
// ping in favor of server/discover, logging/setLevel in favor of _meta logLevel, and the
129+
// resource subscription pair in favor of subscriptions/listen). A request for a method the
130+
// server does not implement is rejected with 404 Not Found and Method not found. Rejecting
131+
// here gives HTTP the required status; the server protocol boundary enforces the same method
132+
// set for other transports.
133+
#pragma warning disable MCP9005 // logging/setLevel is deprecated (SEP-2577); referenced here only to reject it as removed.
134+
if (RequiresPerRequestMetadataProtocol(context) &&
135+
message is JsonRpcRequest
136+
{
137+
Method: RequestMethods.Initialize or RequestMethods.Ping or RequestMethods.LoggingSetLevel
138+
or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe,
139+
} removedMethodRequest)
140+
#pragma warning restore MCP9005
141+
{
142+
await WriteJsonRpcErrorAsync(context,
143+
$"Method '{removedMethodRequest.Method}' is not available on protocol version '{context.Request.Headers[McpProtocolVersionHeaderName]}'.",
144+
StatusCodes.Status404NotFound, (int)McpErrorCode.MethodNotFound, requestId);
145+
return;
146+
}
147+
119148
var session = await GetOrCreateSessionAsync(context, message, requestId);
120149
if (session is null)
121150
{
@@ -124,8 +153,33 @@ await WriteJsonRpcErrorAsync(context,
124153

125154
await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);
126155

156+
Func<JsonRpcMessage?, ValueTask>? onResponseStarting = null;
157+
if (RequiresPerRequestMetadataProtocol(context))
158+
{
159+
// SEP-2575 maps some JSON-RPC error codes onto HTTP statuses (404 for a method the server
160+
// does not implement, 400 for missing-capability and unsupported-version rejections). The
161+
// status line can only be chosen before the first response byte, so the transport defers
162+
// its eager header flush and reports the first response message here.
163+
onResponseStarting = firstMessage =>
164+
{
165+
if (firstMessage is JsonRpcError { Error: { } errorDetail } && !context.Response.HasStarted)
166+
{
167+
context.Response.StatusCode = (McpErrorCode)errorDetail.Code switch
168+
{
169+
McpErrorCode.MethodNotFound => StatusCodes.Status404NotFound,
170+
McpErrorCode.MissingRequiredClientCapability => StatusCodes.Status400BadRequest,
171+
McpErrorCode.UnsupportedProtocolVersion => StatusCodes.Status400BadRequest,
172+
McpErrorCode.HeaderMismatch => StatusCodes.Status400BadRequest,
173+
_ => context.Response.StatusCode,
174+
};
175+
}
176+
177+
return default;
178+
};
179+
}
180+
127181
InitializeSseResponse(context);
128-
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted);
182+
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, onResponseStarting, context.RequestAborted);
129183
if (!wroteResponse)
130184
{
131185
// We wound up writing nothing, so there should be no Content-Type response header.
@@ -137,7 +191,7 @@ await WriteJsonRpcErrorAsync(context,
137191
public async Task HandleGetRequestAsync(HttpContext context)
138192
{
139193
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
140-
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
194+
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
141195
{
142196
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
143197
return;
@@ -256,7 +310,7 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
256310
public async Task HandleDeleteRequestAsync(HttpContext context)
257311
{
258312
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
259-
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
313+
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
260314
{
261315
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
262316
return;
@@ -652,6 +706,39 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
652706

653707
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
654708

709+
/// <summary>
710+
/// Validates that a request riding a per-request-metadata protocol revision carries the required
711+
/// <c>_meta</c> fields beyond the protocol version (which <see cref="ValidateProtocolVersionEnvelope"/>
712+
/// already checked): <c>io.modelcontextprotocol/clientCapabilities</c>, which must be present and a
713+
/// JSON object. Performed at the HTTP layer so the rejection can use 400 Bad Request as SEP-2575
714+
/// requires; a malformed (non-object) value would otherwise fail later during deserialization as a
715+
/// generic internal error on a 200 response. <c>clientInfo</c> is optional, so its absence is not
716+
/// rejected.
717+
/// </summary>
718+
private static bool ValidateRequiredPerRequestMeta(
719+
HttpContext context,
720+
JsonRpcMessage message,
721+
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
722+
{
723+
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
724+
if (message is JsonRpcRequest { Params: var requestParams } &&
725+
McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
726+
(requestParams is not JsonObject paramsObj ||
727+
paramsObj["_meta"] is not JsonObject metaObj ||
728+
metaObj[MetaKeys.ClientCapabilities] is not JsonObject))
729+
{
730+
errorDetail = new JsonRpcErrorDetail
731+
{
732+
Code = (int)McpErrorCode.InvalidParams,
733+
Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ClientCapabilities}' as a JSON object.",
734+
};
735+
return false;
736+
}
737+
738+
errorDetail = null;
739+
return true;
740+
}
741+
655742
/// <summary>
656743
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
657744
/// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the
@@ -661,20 +748,38 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
661748
private static bool ValidateProtocolVersionHeader(
662749
HttpContext context,
663750
IReadOnlyList<string> supportedProtocolVersions,
751+
bool stateless,
664752
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
665753
{
666754
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
667755
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
668756
!supportedProtocolVersions.Contains(protocolVersionHeader))
669757
{
758+
// On a stateless server, restrict the advertised list to the per-request-metadata
759+
// revisions: those are what server/discover advertises, and SEP-2575 clients use this
760+
// list to pick a retry version for server/discover, so the two must agree. Requests for
761+
// the older initialize-handshake revisions are still accepted above; only the error
762+
// payload for unknown versions narrows. Stateful servers keep the full configured list
763+
// (their 2026-07-28 refusal advertises the session-supporting versions separately in
764+
// WriteUnsupportedProtocolVersionErrorAsync).
765+
IReadOnlyList<string> advertisedVersions = supportedProtocolVersions;
766+
if (stateless)
767+
{
768+
var metadataVersions = supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata).ToArray();
769+
if (metadataVersions.Length > 0)
770+
{
771+
advertisedVersions = metadataVersions;
772+
}
773+
}
774+
670775
errorDetail = new JsonRpcErrorDetail
671776
{
672777
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
673778
Message = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.",
674779
Data = JsonSerializer.SerializeToNode(
675780
new UnsupportedProtocolVersionErrorData
676781
{
677-
Supported = [.. supportedProtocolVersions],
782+
Supported = [.. advertisedVersions],
678783
Requested = protocolVersionHeader,
679784
},
680785
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
@@ -749,8 +854,23 @@ private static bool ValidateProtocolVersionEnvelope(
749854

750855
if (!hasProtocolVersionMeta)
751856
{
752-
errorDetail = CreateHeaderMismatchError(
753-
$"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version.");
857+
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
858+
// (a JSON-RPC error response has no recipient), and rejecting them silently drops
859+
// client signals like notifications/cancelled, leaving server-side work running.
860+
if (message is not JsonRpcRequest)
861+
{
862+
errorDetail = null;
863+
return true;
864+
}
865+
866+
// A missing (rather than mismatched) per-request metadata field is an Invalid params
867+
// rejection per SEP-2575; HeaderMismatch (-32020) is reserved for values that are
868+
// present on both sides but disagree.
869+
errorDetail = new JsonRpcErrorDetail
870+
{
871+
Code = (int)McpErrorCode.InvalidParams,
872+
Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ProtocolVersion}'.",
873+
};
754874
return false;
755875
}
756876

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,12 @@ public Task SubscribeToResourceAsync(string uri, RequestOptions? options = null,
733733
/// For a simpler API that handles both subscription and notification registration in a single call,
734734
/// use <see cref="SubscribeToResourceAsync(Uri, Func{ResourceUpdatedNotificationParams, CancellationToken, ValueTask}, RequestOptions?, CancellationToken)"/>.
735735
/// </para>
736+
/// <para>
737+
/// The 2026-07-28 protocol revision (SEP-2575) removed <c>resources/subscribe</c> in favor of
738+
/// <see cref="RequestMethods.SubscriptionsListen"/> with <c>resourceSubscriptions</c>. On a session that
739+
/// negotiated that revision or later, this method throws an <see cref="McpException"/> with
740+
/// <see cref="McpErrorCode.MethodNotFound"/>.
741+
/// </para>
736742
/// </remarks>
737743
public Task SubscribeToResourceAsync(
738744
SubscribeRequestParams requestParams,
@@ -931,6 +937,12 @@ public Task UnsubscribeFromResourceAsync(string uri, RequestOptions? options = n
931937
/// <returns>The result of the request.</returns>
932938
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
933939
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
940+
/// <remarks>
941+
/// The 2026-07-28 protocol revision (SEP-2575) removed <c>resources/unsubscribe</c> in favor of
942+
/// <see cref="RequestMethods.SubscriptionsListen"/> with <c>resourceSubscriptions</c>. On a session that
943+
/// negotiated that revision or later, this method throws an <see cref="McpException"/> with
944+
/// <see cref="McpErrorCode.MethodNotFound"/>.
945+
/// </remarks>
934946
public Task UnsubscribeFromResourceAsync(
935947
UnsubscribeRequestParams requestParams,
936948
CancellationToken cancellationToken = default)

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ protected McpClient()
2929
/// <remarks>
3030
/// <para>
3131
/// This property provides identification details about the connected server, including its name and version.
32-
/// It is populated during the initialization handshake and is available after a successful connection.
32+
/// It is populated during the initialization handshake or from <c>server/discover</c> result metadata.
3333
/// </para>
3434
/// <para>
3535
/// This information can be useful for logging, debugging, compatibility checks, and displaying server
3636
/// information to users.
3737
/// </para>
3838
/// </remarks>
39-
/// <exception cref="InvalidOperationException">The client is not connected.</exception>
39+
/// <exception cref="InvalidOperationException">
40+
/// The client is not connected, or the server omitted the optional server identity metadata.
41+
/// </exception>
4042
public abstract Implementation ServerInfo { get; }
4143

4244
/// <summary>

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
170170
public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected.");
171171

172172
/// <inheritdoc/>
173-
public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected.");
173+
public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException(
174+
"The client is not connected, or the connected server did not provide optional server identity metadata.");
174175

175176
/// <inheritdoc/>
176177
public override string? ServerInstructions => _serverInstructions;
@@ -432,15 +433,19 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
432433
}
433434
else
434435
{
436+
var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!);
437+
435438
if (_logger.IsEnabled(LogLevel.Information))
436439
{
437440
LogServerCapabilitiesReceived(_endpointName,
438441
capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
439-
serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
442+
serverInfo: discoveredServerInfo is null
443+
? "(none)"
444+
: JsonSerializer.Serialize(discoveredServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
440445
}
441446

442447
_serverCapabilities = discoverResult!.Capabilities;
443-
_serverInfo = discoverResult.ServerInfo;
448+
_serverInfo = discoveredServerInfo;
444449
_serverInstructions = discoverResult.Instructions;
445450
}
446451

@@ -484,6 +489,30 @@ async Task<DiscoverResult> SendDiscoverAsync(string protocolVersion, Cancellatio
484489
LogClientConnected(_endpointName);
485490
}
486491

492+
/// <summary>
493+
/// Resolves the server identity from a <c>server/discover</c> result. The 2026-07-28 revision carries
494+
/// <c>serverInfo</c> in the result's <c>_meta/io.modelcontextprotocol/serverInfo</c> field rather than
495+
/// the result body. Identity is optional, so a server that omits it yields <see langword="null"/>.
496+
/// </summary>
497+
private static Implementation? GetServerInfoFromDiscover(DiscoverResult discoverResult)
498+
{
499+
if (discoverResult.Meta is { } meta &&
500+
meta.TryGetPropertyValue(MetaKeys.ServerInfo, out JsonNode? serverInfoNode))
501+
{
502+
if (serverInfoNode is null)
503+
{
504+
throw new JsonException(
505+
$"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation.");
506+
}
507+
508+
return JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.JsonContext.Default.Implementation)
509+
?? throw new JsonException(
510+
$"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation.");
511+
}
512+
513+
return null;
514+
}
515+
487516
/// <summary>
488517
/// Performs the initialize handshake (initialize request + initialized notification),
489518
/// records the negotiated protocol version, and stores the server capabilities/info/instructions.

0 commit comments

Comments
 (0)