@@ -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
0 commit comments