88using ModelContextProtocol . Protocol ;
99using ModelContextProtocol . Server ;
1010using System . Collections . Concurrent ;
11+ using System . Diagnostics . CodeAnalysis ;
1112using System . Security . Claims ;
1213using System . Security . Cryptography ;
14+ using System . Text . Json ;
1315using System . Text . Json . Serialization . Metadata ;
1416
1517namespace ModelContextProtocol . AspNetCore ;
@@ -51,9 +53,9 @@ internal sealed class StreamableHttpHandler(
5153
5254 public async Task HandlePostRequestAsync ( HttpContext context )
5355 {
54- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
56+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
5557 {
56- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
58+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
5759 return ;
5860 }
5961
@@ -99,9 +101,24 @@ await WriteJsonRpcErrorAsync(context,
99101
100102 public async Task HandleGetRequestAsync ( HttpContext context )
101103 {
102- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
104+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
103105 {
104- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
106+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
107+ return ;
108+ }
109+
110+ var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
111+
112+ // Under the draft protocol revision (SEP-2575), the standalone HTTP GET endpoint for unsolicited
113+ // server-to-client messages is removed. Clients should use subscriptions/listen (POST) instead.
114+ // We only reject GET when the request looks like a draft-mode probe (experimental version with
115+ // no Mcp-Session-Id); legacy stateful sessions that opted into MRTR via the experimental version
116+ // are still allowed to use GET for back-compat.
117+ if ( IsDraftProtocolRequest ( context ) && string . IsNullOrEmpty ( sessionId ) )
118+ {
119+ await WriteJsonRpcErrorAsync ( context ,
120+ "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead." ,
121+ StatusCodes . Status400BadRequest ) ;
105122 return ;
106123 }
107124
@@ -113,7 +130,6 @@ await WriteJsonRpcErrorAsync(context,
113130 return ;
114131 }
115132
116- var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
117133 var session = await GetSessionAsync ( context , sessionId ) ;
118134 if ( session is null )
119135 {
@@ -202,13 +218,25 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
202218
203219 public async Task HandleDeleteRequestAsync ( HttpContext context )
204220 {
205- if ( ! ValidateProtocolVersionHeader ( context , out var errorMessage ) )
221+ if ( ! ValidateProtocolVersionHeader ( context , out var protocolVersionError ) )
206222 {
207- await WriteJsonRpcErrorAsync ( context , errorMessage ! , StatusCodes . Status400BadRequest ) ;
223+ await WriteJsonRpcErrorDetailAsync ( context , protocolVersionError , StatusCodes . Status400BadRequest ) ;
208224 return ;
209225 }
210226
211227 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
228+
229+ // Under the draft revision there are no sessions to terminate. Reject DELETE requests that
230+ // declare the draft version without an Mcp-Session-Id. Legacy stateful sessions opted into
231+ // the experimental version may still call DELETE for back-compat.
232+ if ( IsDraftProtocolRequest ( context ) && string . IsNullOrEmpty ( sessionId ) )
233+ {
234+ await WriteJsonRpcErrorAsync ( context ,
235+ "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (no Mcp-Session-Id sessions exist)." ,
236+ StatusCodes . Status400BadRequest ) ;
237+ return ;
238+ }
239+
212240 if ( sessionManager . TryRemove ( sessionId , out var session ) )
213241 {
214242 await session . DisposeAsync ( ) ;
@@ -293,6 +321,22 @@ await WriteJsonRpcErrorAsync(context,
293321 private async ValueTask < StreamableHttpSession ? > GetOrCreateSessionAsync ( HttpContext context , JsonRpcMessage message )
294322 {
295323 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
324+ bool isDraftRequest = IsDraftProtocolRequest ( context ) ;
325+
326+ // Under the draft protocol revision (SEP-2575 + SEP-2567), sessions are removed entirely.
327+ // A request that declares the experimental draft version via MCP-Protocol-Version and that
328+ // does NOT include an Mcp-Session-Id is treated as sessionless regardless of the
329+ // HttpServerTransportOptions.Stateless setting (which governs only legacy clients).
330+ //
331+ // For back-compat with clients that previously used the experimental version on top of the
332+ // legacy stateful session model (e.g., MRTR-as-extension-on-initialize), we still route
333+ // experimental-version requests that DO include an Mcp-Session-Id through the legacy session
334+ // lookup path. SEP-2567 will eventually phase that out, but we preserve it now to avoid
335+ // breaking existing consumers without forcing them to change their setup code.
336+ if ( isDraftRequest && string . IsNullOrEmpty ( sessionId ) )
337+ {
338+ return await StartNewSessionAsync ( context , forceStateless : true ) ;
339+ }
296340
297341 if ( string . IsNullOrEmpty ( sessionId ) )
298342 {
@@ -322,12 +366,33 @@ await WriteJsonRpcErrorAsync(context,
322366 }
323367 }
324368
325- private async ValueTask < StreamableHttpSession > StartNewSessionAsync ( HttpContext context )
369+ /// <summary>
370+ /// Returns <see langword="true"/> when the request declares the experimental draft protocol revision via
371+ /// the <c>MCP-Protocol-Version</c> header. Draft requests are always sessionless and do not perform
372+ /// the legacy <c>initialize</c> handshake (SEP-2575 + SEP-2567).
373+ /// </summary>
374+ private bool IsDraftProtocolRequest ( HttpContext context )
375+ {
376+ #pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is for evaluation purposes only
377+ var experimental = mcpServerOptionsSnapshot . Value . ExperimentalProtocolVersion ;
378+ #pragma warning restore MCPEXP001
379+ if ( experimental is null )
380+ {
381+ return false ;
382+ }
383+
384+ var protocolVersionHeader = context . Request . Headers [ McpProtocolVersionHeaderName ] . ToString ( ) ;
385+ return string . Equals ( protocolVersionHeader , experimental , StringComparison . Ordinal ) ;
386+ }
387+
388+ private async ValueTask < StreamableHttpSession > StartNewSessionAsync ( HttpContext context , bool forceStateless = false )
326389 {
327390 string sessionId ;
328391 StreamableHttpServerTransport transport ;
329392
330- if ( ! HttpServerTransportOptions . Stateless )
393+ bool isStateless = HttpServerTransportOptions . Stateless || forceStateless ;
394+
395+ if ( ! isStateless )
331396 {
332397 sessionId = MakeNewSessionId ( ) ;
333398 transport = new ( loggerFactory )
@@ -344,7 +409,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
344409 }
345410 else
346411 {
347- // In stateless mode, each request is independent. Don't set any session ID on the transport.
412+ // In stateless mode (legacy or draft) , each request is independent. Don't set any session ID on the transport.
348413 // If in the future we support resuming stateless requests, we should populate
349414 // the event stream store and retry interval here as well.
350415 sessionId = "" ;
@@ -354,22 +419,25 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
354419 } ;
355420 }
356421
357- return await CreateSessionAsync ( context , transport , sessionId ) ;
422+ return await CreateSessionAsync ( context , transport , sessionId , forceStateless : forceStateless ) ;
358423 }
359424
360425 private async ValueTask < StreamableHttpSession > CreateSessionAsync (
361426 HttpContext context ,
362427 StreamableHttpServerTransport transport ,
363428 string sessionId ,
364- Action < McpServerOptions > ? configureOptions = null )
429+ Action < McpServerOptions > ? configureOptions = null ,
430+ bool forceStateless = false )
365431 {
366432 var mcpServerServices = applicationServices ;
367433 var mcpServerOptions = mcpServerOptionsSnapshot . Value ;
368- if ( HttpServerTransportOptions . Stateless || HttpServerTransportOptions . ConfigureSessionOptions is not null || configureOptions is not null )
434+ bool effectivelyStateless = HttpServerTransportOptions . Stateless || forceStateless ;
435+
436+ if ( effectivelyStateless || HttpServerTransportOptions . ConfigureSessionOptions is not null || configureOptions is not null )
369437 {
370438 mcpServerOptions = mcpServerOptionsFactory . Create ( Options . DefaultName ) ;
371439
372- if ( HttpServerTransportOptions . Stateless )
440+ if ( effectivelyStateless )
373441 {
374442 // The session does not outlive the request in stateless mode.
375443 mcpServerServices = context . RequestServices ;
@@ -531,23 +599,51 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
531599
532600 /// <summary>
533601 /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
534- /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
602+ /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the
603+ /// rejection uses the <see cref="McpErrorCode.UnsupportedProtocolVersion"/> error code with a data payload
604+ /// listing the server's supported versions so the client can select a fallback.
535605 /// </summary>
536- private bool ValidateProtocolVersionHeader ( HttpContext context , out string ? errorMessage )
606+ private bool ValidateProtocolVersionHeader ( HttpContext context , [ NotNullWhen ( false ) ] out JsonRpcErrorDetail ? errorDetail )
537607 {
538608 var protocolVersionHeader = context . Request . Headers [ McpProtocolVersionHeaderName ] . ToString ( ) ;
609+ #pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is for evaluation purposes only
610+ var experimental = mcpServerOptionsSnapshot . Value . ExperimentalProtocolVersion ;
611+ #pragma warning restore MCPEXP001
539612 if ( ! string . IsNullOrEmpty ( protocolVersionHeader ) &&
540613 ! s_supportedProtocolVersions . Contains ( protocolVersionHeader ) &&
541- ! ( mcpServerOptionsSnapshot . Value . ExperimentalProtocolVersion is { } experimentalVersion && protocolVersionHeader == experimentalVersion ) )
614+ ! ( experimental is not null && protocolVersionHeader == experimental ) )
542615 {
543- errorMessage = $ "Bad Request: The MCP-Protocol-Version header value '{ protocolVersionHeader } ' is not supported.";
616+ var supported = new List < string > ( s_supportedProtocolVersions ) ;
617+ if ( experimental is not null )
618+ {
619+ supported . Add ( experimental ) ;
620+ }
621+
622+ errorDetail = new JsonRpcErrorDetail
623+ {
624+ Code = ( int ) McpErrorCode . UnsupportedProtocolVersion ,
625+ Message = $ "Bad Request: The MCP-Protocol-Version header value '{ protocolVersionHeader } ' is not supported.",
626+ Data = JsonSerializer . SerializeToNode (
627+ new UnsupportedProtocolVersionErrorData
628+ {
629+ Supported = supported ,
630+ Requested = protocolVersionHeader ,
631+ } ,
632+ GetRequiredJsonTypeInfo < UnsupportedProtocolVersionErrorData > ( ) ) ,
633+ } ;
544634 return false ;
545635 }
546636
547- errorMessage = null ;
637+ errorDetail = null ;
548638 return true ;
549639 }
550640
641+ private static Task WriteJsonRpcErrorDetailAsync ( HttpContext context , JsonRpcErrorDetail detail , int statusCode )
642+ {
643+ var jsonRpcError = new JsonRpcError { Error = detail } ;
644+ return Results . Json ( jsonRpcError , s_errorTypeInfo , statusCode : statusCode ) . ExecuteAsync ( context ) ;
645+ }
646+
551647 private static bool MatchesApplicationJsonMediaType ( MediaTypeHeaderValue acceptHeaderValue )
552648 => acceptHeaderValue . MatchesMediaType ( "application/json" ) ;
553649
0 commit comments