Skip to content

Commit f15eb86

Browse files
halter73Copilot
andcommitted
Add SEP-2575 + SEP-2567 draft protocol support
Implements the protocol-level changes for the draft revision (SEP-2575 stateless MCP and SEP-2567 sessionless MCP): - New _meta keys for per-request protocolVersion / clientInfo / clientCapabilities / logLevel - New RPCs: server/discover and subscriptions/listen, plus the acknowledgement notification - New JSON-RPC error codes -32004 (UnsupportedProtocolVersion) and -32003 (MissingRequiredClientCapability) with typed exception classes - Client skips initialize under draft mode, calls server/discover instead, and falls back to legacy initialize when the server doesn't support the experimental version - Server keeps the legacy initialize handler for back-compat, and a new built-in incoming message filter projects per-request _meta values onto the per-session client info/capabilities/version state under draft - HTTP server suppresses Mcp-Session-Id and routes draft requests through the stateless path regardless of HttpServerTransportOptions.Stateless - HTTP server returns -32004 with a structured supportedVersions data payload when a client requests an unsupported protocol version - HTTP client transport carries the protocol version header on every request (sourced from per-request _meta when present), and surfaces -32004/-32003 from HTTP error responses as typed McpProtocolException for the connection logic to react Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 719eade commit f15eb86

20 files changed

Lines changed: 1186 additions & 88 deletions

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.Diagnostics.CodeAnalysis;
1313
using System.Security.Claims;
1414
using System.Security.Cryptography;
15+
using System.Text.Json;
1516
using System.Text.Json.Serialization.Metadata;
1617

1718
namespace 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

Comments
 (0)