1212using System . Diagnostics . CodeAnalysis ;
1313using System . Security . Claims ;
1414using System . Security . Cryptography ;
15+ using System . Text . Json ;
1516using System . Text . Json . Serialization . Metadata ;
1617
1718namespace ModelContextProtocol . AspNetCore ;
@@ -54,9 +55,9 @@ internal sealed class StreamableHttpHandler(
5455
5556 public async Task HandlePostRequestAsync ( HttpContext context )
5657 {
57- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
58+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
5859 {
59- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
60+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
6061 return ;
6162 }
6263
@@ -108,9 +109,24 @@ await WriteJsonRpcErrorAsync(context,
108109
109110 public async Task HandleGetRequestAsync ( HttpContext context )
110111 {
111- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
112+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
112113 {
113- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
114+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
115+ return ;
116+ }
117+
118+ var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
119+
120+ // Under the draft protocol revision (SEP-2575), the standalone HTTP GET endpoint for unsolicited
121+ // server-to-client messages is removed. Clients should use subscriptions/listen (POST) instead.
122+ // We only reject GET when the request looks like a draft-mode probe (experimental version with
123+ // no Mcp-Session-Id); legacy stateful sessions that opted into MRTR via the experimental version
124+ // are still allowed to use GET for back-compat.
125+ if ( IsDraftProtocolRequest ( context ) && string . IsNullOrEmpty ( sessionId ) )
126+ {
127+ await WriteJsonRpcErrorAsync ( context ,
128+ "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead." ,
129+ StatusCodes . Status400BadRequest ) ;
114130 return ;
115131 }
116132
@@ -122,7 +138,6 @@ await WriteJsonRpcErrorAsync(context,
122138 return ;
123139 }
124140
125- var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
126141 var session = await GetSessionAsync ( context , sessionId ) ;
127142 if ( session is null )
128143 {
@@ -211,13 +226,25 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
211226
212227 public async Task HandleDeleteRequestAsync ( HttpContext context )
213228 {
214- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
229+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
215230 {
216- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
231+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
217232 return ;
218233 }
219234
220235 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
236+
237+ // Under the draft revision there are no sessions to terminate. Reject DELETE requests that
238+ // declare the draft version without an Mcp-Session-Id. Legacy stateful sessions opted into
239+ // the draft version may still call DELETE for back-compat.
240+ if ( IsDraftProtocolRequest ( context ) && string . IsNullOrEmpty ( sessionId ) )
241+ {
242+ await WriteJsonRpcErrorAsync ( context ,
243+ "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (no Mcp-Session-Id sessions exist)." ,
244+ StatusCodes . Status400BadRequest ) ;
245+ return ;
246+ }
247+
221248 if ( string . IsNullOrEmpty ( sessionId ) || ! sessionManager . TryGetValue ( sessionId , out var session ) )
222249 {
223250 return ;
@@ -319,6 +346,22 @@ await WriteJsonRpcErrorAsync(context,
319346 private async ValueTask < StreamableHttpSession ? > GetOrCreateSessionAsync ( HttpContext context , JsonRpcMessage message )
320347 {
321348 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
349+ bool isDraftRequest = IsDraftProtocolRequest ( context ) ;
350+
351+ // Under the draft protocol revision (SEP-2575 + SEP-2567), sessions are removed entirely.
352+ // A request that declares the experimental draft version via MCP-Protocol-Version and that
353+ // does NOT include an Mcp-Session-Id is treated as sessionless regardless of the
354+ // HttpServerTransportOptions.Stateless setting (which governs only legacy clients).
355+ //
356+ // For back-compat with clients that previously used the experimental version on top of the
357+ // legacy stateful session model (e.g., MRTR-as-extension-on-initialize), we still route
358+ // experimental-version requests that DO include an Mcp-Session-Id through the legacy session
359+ // lookup path. SEP-2567 will eventually phase that out, but we preserve it now to avoid
360+ // breaking existing consumers without forcing them to change their setup code.
361+ if ( isDraftRequest && string . IsNullOrEmpty ( sessionId ) )
362+ {
363+ return await StartNewSessionAsync ( context , forceStateless : true ) ;
364+ }
322365
323366 if ( string . IsNullOrEmpty ( sessionId ) )
324367 {
@@ -350,12 +393,33 @@ await WriteJsonRpcErrorAsync(context,
350393 }
351394 }
352395
353- private async ValueTask < StreamableHttpSession > StartNewSessionAsync ( HttpContext context )
396+ /// <summary>
397+ /// Returns <see langword="true"/> when the request declares the experimental draft protocol revision via
398+ /// the <c>MCP-Protocol-Version</c> header. Draft requests are always sessionless and do not perform
399+ /// the legacy <c>initialize</c> handshake (SEP-2575 + SEP-2567).
400+ /// </summary>
401+ private bool IsDraftProtocolRequest ( HttpContext context )
402+ {
403+ #pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is for evaluation purposes only
404+ var experimental = mcpServerOptionsSnapshot . Value . ExperimentalProtocolVersion ;
405+ #pragma warning restore MCPEXP001
406+ if ( experimental is null )
407+ {
408+ return false ;
409+ }
410+
411+ var protocolVersionHeader = context . Request . Headers [ McpProtocolVersionHeaderName ] . ToString ( ) ;
412+ return string . Equals ( protocolVersionHeader , experimental , StringComparison . Ordinal ) ;
413+ }
414+
415+ private async ValueTask < StreamableHttpSession > StartNewSessionAsync ( HttpContext context , bool forceStateless = false )
354416 {
355417 string sessionId ;
356418 StreamableHttpServerTransport transport ;
357419
358- if ( ! HttpServerTransportOptions . Stateless )
420+ bool isStateless = HttpServerTransportOptions . Stateless || forceStateless ;
421+
422+ if ( ! isStateless )
359423 {
360424 sessionId = MakeNewSessionId ( ) ;
361425 transport = new ( loggerFactory )
@@ -372,7 +436,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
372436 }
373437 else
374438 {
375- // In stateless mode, each request is independent. Don't set any session ID on the transport.
439+ // In stateless mode (legacy or draft) , each request is independent. Don't set any session ID on the transport.
376440 // If in the future we support resuming stateless requests, we should populate
377441 // the event stream store and retry interval here as well.
378442 sessionId = "" ;
@@ -382,22 +446,25 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
382446 } ;
383447 }
384448
385- return await CreateSessionAsync ( context , transport , sessionId ) ;
449+ return await CreateSessionAsync ( context , transport , sessionId , forceStateless : forceStateless ) ;
386450 }
387451
388452 private async ValueTask < StreamableHttpSession > CreateSessionAsync (
389453 HttpContext context ,
390454 StreamableHttpServerTransport transport ,
391455 string sessionId ,
392- Action < McpServerOptions > ? configureOptions = null )
456+ Action < McpServerOptions > ? configureOptions = null ,
457+ bool forceStateless = false )
393458 {
394459 var mcpServerServices = applicationServices ;
395460 var mcpServerOptions = mcpServerOptionsSnapshot . Value ;
396- if ( HttpServerTransportOptions . Stateless || HttpServerTransportOptions . ConfigureSessionOptions is not null || configureOptions is not null )
461+ bool effectivelyStateless = HttpServerTransportOptions . Stateless || forceStateless ;
462+
463+ if ( effectivelyStateless || HttpServerTransportOptions . ConfigureSessionOptions is not null || configureOptions is not null )
397464 {
398465 mcpServerOptions = mcpServerOptionsFactory . Create ( Options . DefaultName ) ;
399466
400- if ( HttpServerTransportOptions . Stateless )
467+ if ( effectivelyStateless )
401468 {
402469 // The session does not outlive the request in stateless mode.
403470 mcpServerServices = context . RequestServices ;
@@ -559,19 +626,32 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
559626
560627 /// <summary>
561628 /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
562- /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
629+ /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the
630+ /// rejection uses the <see cref="McpErrorCode.UnsupportedProtocolVersion"/> error code with a data payload
631+ /// listing the server's supported versions so the client can select a fallback.
563632 /// </summary>
564- private static bool ValidateProtocolVersionHeader ( HttpContext context , out string ? errorMessage )
633+ private static bool ValidateProtocolVersionHeader ( HttpContext context , [ NotNullWhen ( false ) ] out JsonRpcErrorDetail ? errorDetail )
565634 {
566635 var protocolVersionHeader = context . Request . Headers [ McpProtocolVersionHeaderName ] . ToString ( ) ;
567636 if ( ! string . IsNullOrEmpty ( protocolVersionHeader ) &&
568637 ! s_supportedProtocolVersions . Contains ( protocolVersionHeader ) )
569638 {
570- errorMessage = $ "Bad Request: The MCP-Protocol-Version header value '{ protocolVersionHeader } ' is not supported.";
639+ errorDetail = new JsonRpcErrorDetail
640+ {
641+ Code = ( int ) McpErrorCode . UnsupportedProtocolVersion ,
642+ Message = $ "Bad Request: The MCP-Protocol-Version header value '{ protocolVersionHeader } ' is not supported.",
643+ Data = JsonSerializer . SerializeToNode (
644+ new UnsupportedProtocolVersionErrorData
645+ {
646+ Supported = [ .. s_supportedProtocolVersions ] ,
647+ Requested = protocolVersionHeader ,
648+ } ,
649+ GetRequiredJsonTypeInfo < UnsupportedProtocolVersionErrorData > ( ) ) ,
650+ } ;
571651 return false ;
572652 }
573653
574- errorMessage = null ;
654+ errorDetail = null ;
575655 return true ;
576656 }
577657
@@ -858,6 +938,12 @@ private static bool ValuesMatch(string? actual, string? expected, System.Text.Js
858938 return false ;
859939 }
860940
941+ private static Task WriteJsonRpcErrorDetailAsync ( HttpContext context , JsonRpcErrorDetail detail , int statusCode )
942+ {
943+ var jsonRpcError = new JsonRpcError { Error = detail } ;
944+ return Results . Json ( jsonRpcError , s_errorTypeInfo , statusCode : statusCode ) . ExecuteAsync ( context ) ;
945+ }
946+
861947 private static bool MatchesApplicationJsonMediaType ( MediaTypeHeaderValue acceptHeaderValue )
862948 => acceptHeaderValue . MatchesMediaType ( "application/json" ) ;
863949
0 commit comments