Skip to content

Commit 46970b6

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 fd2d71e commit 46970b6

20 files changed

Lines changed: 1205 additions & 102 deletions

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 115 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
using ModelContextProtocol.Protocol;
99
using ModelContextProtocol.Server;
1010
using System.Collections.Concurrent;
11+
using System.Diagnostics.CodeAnalysis;
1112
using System.Security.Claims;
1213
using System.Security.Cryptography;
14+
using System.Text.Json;
1315
using System.Text.Json.Serialization.Metadata;
1416

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

Comments
 (0)