Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving (#1796)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
This commit is contained in:
pardhu 2026-08-12 23:36:36 -07:00 • committed by GitHub
commit c6a97d94d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 764 additions and 150 deletions

View file

@ -44,7 +44,7 @@ internal static class Obsoletions
public const string DeprecatedLogging_Message = "The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information.";
public const string LegacyStatefulHttp_DiagnosticId = "MCP9006";
public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567.";
public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for 2025-11-25 protocol revision clients and earlier. Set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567.";
public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007";

View file

@ -0,0 +1,64 @@
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Specifies how the Streamable HTTP transport tracks state between requests.
/// </summary>
/// <remarks>
/// Starting with the <c>2026-07-28</c> protocol revision, Streamable HTTP no longer supports sessions
/// (SEP-2567 removed <c>Mcp-Session-Id</c>, and SEP-2575 removed the <c>initialize</c> handshake), so requests
/// using that revision or later can only ever be served statelessly. This enumeration allows specification for
/// how the server reconciles that requirement with clients that still rely on the <c>initialize</c> handshake.
/// </remarks>
public enum HttpServerSessionMode
{
/// <summary>
/// The server never tracks state between requests, allowing for load balancing without session affinity.
/// </summary>
/// <remarks>
/// <see cref="McpSession.SessionId"/> is <see langword="null"/>, the
/// <c>Mcp-Session-Id</c> header is unused, <see cref="HttpServerTransportOptions.RunSessionHandler"/> and
/// <see cref="HttpServerTransportOptions.ConfigureSessionOptions"/> are invoked once per request, and the
/// GET, DELETE, and <c>/sse</c> endpoints are unavailable. Unsolicited server-to-client messages and all
/// server-to-client requests are unsupported because any response might arrive at another ASP.NET Core
/// application process. Client sampling, elicitation, and roots capabilities are disabled because the
/// server cannot make requests; use <see href="https://csharp.sdk.modelcontextprotocol.io/v2/concepts/mrtr/mrtr.html">Multi Round-Trip Requests (MRTR)</see>
/// instead.
/// </remarks>
Stateless,
/// <summary>
/// The server tracks a long-lived session for every client, which requires session affinity.
/// </summary>
/// <remarks>
/// Requests using the <c>2026-07-28</c> or later protocol revision are refused with a
/// <c>-32022 UnsupportedProtocolVersion</c> error so that a dual-path client downgrades to the
/// <c>initialize</c> handshake and obtains the session the server was configured to provide. Use
/// <see cref="StatefulForInitializeClients"/> to serve those clients natively instead of forcing a downgrade.
/// </remarks>
Stateful,
/// <summary>
/// The server tracks a long-lived session for clients that use the <c>initialize</c> handshake and serves
/// clients using the <c>2026-07-28</c> or later protocol revision statelessly on the same endpoint.
/// </summary>
/// <remarks>
/// <para>
/// This hybrid mode allows an application to adopt the latest protocol revision progressively rather than
/// waiting for every client to migrate. Clients using the <c>2025-11-25</c> or earlier revisions get a full
/// stateful session with an <c>Mcp-Session-Id</c> and continue to use the GET and DELETE endpoints, while
/// clients using the <c>2026-07-28</c> or later revisions are served per request with no session ID minted
/// or echoed, and receive <c>405 Method Not Allowed</c> for GET and DELETE, exactly as in
/// <see cref="Stateless"/> mode.
/// </para>
/// <para>
/// Because a <c>2026-07-28</c> request has no session, the session-only features listed on
/// <see cref="Stateless"/> remain unavailable to those clients even though other clients on the same
/// endpoint have sessions. <see cref="HttpServerTransportOptions.ConfigureSessionOptions"/> is invoked once
/// per session for <c>initialize</c>-handshake clients and once per request for <c>2026-07-28</c> and later
/// clients.
/// </para>
/// </remarks>
StatefulForInitializeClients,
}

View file

@ -18,9 +18,11 @@ public class HttpServerTransportOptions
/// with access to the <see cref="HttpContext"/> of the request that initiated the session.
/// </summary>
/// <remarks>
/// In stateful mode (the default), this callback is invoked once per session when the client sends the
/// <c>initialize</c> request. In <see cref="Stateless"/> mode, it is invoked on <b>every HTTP request</b>
/// because each request creates a fresh server context.
/// In stateful mode, this callback is invoked once per session when the client sends the
/// <c>initialize</c> request. In <see cref="HttpServerSessionMode.Stateless"/> mode, it is invoked on
/// <b>every HTTP request</b> because each request creates a fresh server context. In
/// <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> mode, both apply: once per session for
/// <c>initialize</c>-handshake clients and once per request for <c>2026-07-28</c> and later clients.
/// </remarks>
public Func<HttpContext, McpServerOptions, CancellationToken, Task>? ConfigureSessionOptions { get; set; }
@ -39,12 +41,56 @@ public class HttpServerTransportOptions
/// <see cref="HttpContext"/> of the initializing request with fewer known issues.
/// </para>
/// <para>
/// In <see cref="HttpServerSessionMode.Stateful"/> mode, this callback is invoked once per session. In
/// <see cref="HttpServerSessionMode.Stateless"/> mode, it is invoked once per HTTP request. In
/// <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> mode, both apply: once per session for
/// <c>initialize</c>-handshake clients and once per request for <c>2026-07-28</c> and later clients.
/// </para>
/// <para>
/// This API is experimental and may be removed or change signatures in a future release.
/// </para>
/// </remarks>
[System.Diagnostics.CodeAnalysis.Experimental(Experimentals.RunSessionHandler_DiagnosticId, UrlFormat = Experimentals.RunSessionHandler_Url)]
public Func<HttpContext, McpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
/// <summary>
/// Gets or sets a value that indicates how the server tracks state between requests.
/// </summary>
/// <value>
/// One of the <see cref="HttpServerSessionMode"/> values. The default is
/// <see cref="HttpServerSessionMode.Stateless"/> as of the <c>2026-07-28</c> protocol revision (SEP-2567).
/// </value>
/// <remarks>
/// <para>
/// <see cref="HttpServerSessionMode.Stateless"/> doesn't track state between requests, allowing for load
/// balancing without session affinity. <see cref="McpSession.SessionId"/> will be null, the
/// "MCP-Session-Id" header will not be used, the <see cref="RunSessionHandler"/> will be called once for
/// each request, and the GET, DELETE, and "/sse" endpoints will be disabled. Unsolicited server-to-client
/// messages and all server-to-client requests are also unsupported, because any responses might arrive at
/// another ASP.NET Core application process. Client sampling, elicitation, and roots capabilities are also
/// disabled, because the server cannot make requests.
/// </para>
/// <para>
/// <see cref="HttpServerSessionMode.Stateful"/> tracks a session for every client, which requires session
/// affinity. Starting with the <c>2026-07-28</c> protocol revision, Streamable HTTP no longer supports
/// sessions: the revision removed <c>Mcp-Session-Id</c> (SEP-2567), so such a request is refused with a
/// <c>-32022 UnsupportedProtocolVersion</c> error, and a dual-path client downgrades to the
/// <c>initialize</c> handshake and obtains the session the server was configured to provide.
/// </para>
/// <para>
/// <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> avoids that downgrade by serving
/// <c>2026-07-28</c> and later requests statelessly on the same endpoint while <c>initialize</c>-handshake
/// clients still get full sessions. Session-only features remain unavailable to the stateless half of the
/// endpoint; use <see href="https://csharp.sdk.modelcontextprotocol.io/concepts/mrtr">MRTR</see> for
/// elicitation there.
/// </para>
/// <para>
/// A request that carries an <c>Mcp-Session-Id</c> on the <c>2026-07-28</c> and later revisions is ignored
/// in every mode; the server must not mint or echo session IDs for those revisions.
/// </para>
/// </remarks>
public HttpServerSessionMode SessionMode { get; set; } = HttpServerSessionMode.Stateless;
/// <summary>
/// Gets or sets a value that indicates whether the server runs in a stateless mode that doesn't track state between requests,
/// allowing for load balancing without session affinity.
@ -55,22 +101,18 @@ public class HttpServerTransportOptions
/// set to <see langword="false"/> only when you need to support legacy clients that rely on session affinity.
/// </value>
/// <remarks>
/// If <see langword="true"/>, <see cref="McpSession.SessionId"/> will be null, and the "MCP-Session-Id" header will not be used,
/// the <see cref="RunSessionHandler"/> will be called once for each request, and the "/sse" endpoint will be disabled.
/// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses
/// might arrive at another ASP.NET Core application process.
/// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
/// <para>
/// Starting with the <c>2026-07-28</c> protocol revision, Streamable HTTP no longer supports sessions:
/// the revision removed <c>Mcp-Session-Id</c> (SEP-2567), so over HTTP its requests are only ever served
/// when this property is <see langword="true"/>. When it is <see langword="false"/>, such a request is
/// refused with a <c>-32022 UnsupportedProtocolVersion</c> error so that a dual-path client downgrades to
/// the <c>initialize</c> handshake and obtains the session the server was configured to provide.
/// A request that carries an <c>Mcp-Session-Id</c> on the <c>2026-07-28</c> and later revisions is ignored;
/// the server must not mint or echo session IDs for those revisions.
/// </para>
/// This property is a convenience proxy over <see cref="SessionMode"/>. Reading it returns
/// <see langword="true"/> only when <see cref="SessionMode"/> is <see cref="HttpServerSessionMode.Stateless"/>,
/// so <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> reads as <see langword="false"/>.
/// Assigning <see langword="true"/> selects <see cref="HttpServerSessionMode.Stateless"/> and assigning
/// <see langword="false"/> selects <see cref="HttpServerSessionMode.Stateful"/>. Because both properties
/// update the same underlying value, the last assignment wins when both are configured.
/// </remarks>
public bool Stateless { get; set; } = true;
public bool Stateless
{
get => SessionMode is HttpServerSessionMode.Stateless;
set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful;
}
/// <summary>
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
@ -94,8 +136,9 @@ public class HttpServerTransportOptions
/// built-in backpressure.
/// </para>
/// <para>
/// Setting this to <see langword="true"/> while <see cref="Stateless"/> is also <see langword="true"/>
/// throws an <see cref="InvalidOperationException"/> at startup, because SSE requires in-memory session state.
/// Setting this to <see langword="true"/> while <see cref="SessionMode"/> is
/// <see cref="HttpServerSessionMode.Stateless"/> throws an <see cref="InvalidOperationException"/> at
/// startup, because SSE requires in-memory session state.
/// </para>
/// <para>
/// This property can also be enabled via the <c>ModelContextProtocol.AspNetCore.EnableLegacySse</c>

View file

@ -36,7 +36,7 @@ internal sealed partial class IdleTrackingBackgroundService : BackgroundService
public override Task StartAsync(CancellationToken cancellationToken)
{
// In stateless mode there are no sessions to track, so skip starting the periodic timer entirely.
if (_options.Value.Stateless)
if (_options.Value.SessionMode is HttpServerSessionMode.Stateless)
{
return Task.CompletedTask;
}

View file

@ -32,7 +32,7 @@ public static class McpEndpointRouteBuilderExtensions
var options = streamableHttpHandler.HttpServerTransportOptions;
#pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled
if (options.Stateless && options.EnableLegacySse)
if (options.SessionMode is HttpServerSessionMode.Stateless && options.EnableLegacySse)
{
throw new InvalidOperationException(
"Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " +
@ -50,10 +50,12 @@ public static class McpEndpointRouteBuilderExtensions
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
if (!options.Stateless)
if (options.SessionMode is not HttpServerSessionMode.Stateless)
{
// The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages.
// Resuming streams via GET is currently not supported in Stateless mode.
// In StatefulForInitializeClients mode both endpoints stay mapped for initialize-handshake clients;
// the handlers reject 2026-07-28 and later requests with 405 Method Not Allowed.
streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));

View file

@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler(
/// <summary>
/// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and
/// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path
/// client falls back to the initialize handshake instead of retrying the 2026-07-28 version.
/// later). Used when refusing a 2026-07-28 request on a fully stateful server so a dual-path client falls
/// back to the initialize handshake instead of retrying the 2026-07-28 version.
/// </summary>
private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions;
@ -53,6 +53,14 @@ internal sealed class StreamableHttpHandler(
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
/// <summary>
/// Returns <see langword="true"/> when no request served by this endpoint can have a session. In
/// <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> mode this is <see langword="false"/>
/// even though individual <c>2026-07-28</c> and later requests are still served statelessly, because the
/// endpoint as a whole still tracks sessions for <c>initialize</c>-handshake clients.
/// </summary>
private bool IsStatelessOnly => HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateless;
public async Task HandlePostRequestAsync(HttpContext context)
{
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
@ -100,7 +108,7 @@ internal sealed class StreamableHttpHandler(
// Validated after the body parse (rather than first) so the rejection can echo the request's
// JSON-RPC id: every error response for a parseable request MUST carry its id.
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId);
return;
@ -191,7 +199,7 @@ internal sealed class StreamableHttpHandler(
public async Task HandleGetRequestAsync(HttpContext context)
{
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return;
@ -238,7 +246,7 @@ internal sealed class StreamableHttpHandler(
private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId)
{
if (HttpServerTransportOptions.Stateless)
if (IsStatelessOnly)
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The Last-Event-ID header is not supported in stateless mode.",
@ -310,7 +318,7 @@ internal sealed class StreamableHttpHandler(
public async Task HandleDeleteRequestAsync(HttpContext context)
{
var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError))
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return;
@ -354,7 +362,7 @@ internal sealed class StreamableHttpHandler(
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " +
"If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " +
"If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless. " +
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
StatusCodes.Status400BadRequest, requestId: requestId);
return null;
@ -435,17 +443,18 @@ internal sealed class StreamableHttpHandler(
// and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions:
if (RequiresPerRequestMetadataProtocol(context))
{
if (!HttpServerTransportOptions.Stateless)
if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful)
{
// The author explicitly opted into sessions (Stateless = false), which the 2026-07-28
// revision cannot provide. Refuse it so a dual-path client falls back to the
// initialize handshake and gets the session it asked for (SEP-2575 fallback semantics).
// The author explicitly opted into sessions for every client, which the 2026-07-28 revision
// cannot provide. Refuse it so a dual-path client falls back to the initialize handshake and
// gets the session it asked for (SEP-2575 fallback semantics). StatefulForInitializeClients
// opts out of that downgrade and serves these requests statelessly instead.
await WriteUnsupportedProtocolVersionErrorAsync(context, requestId);
return null;
}
// The default (stateless) HTTP transport serves these requests natively.
return await StartNewSessionAsync(context);
// Stateless and StatefulForInitializeClients both serve these requests natively, without a session.
return await StartNewSessionAsync(context, serveStatelessly: true);
}
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
@ -453,20 +462,20 @@ internal sealed class StreamableHttpHandler(
{
// In stateful mode, only allow creating new sessions for initialize requests.
// In stateless mode, every request is independent, so we always create a new session.
if (!HttpServerTransportOptions.Stateless && !AllowNewSessionForNonInitializeRequests
if (!IsStatelessOnly && !AllowNewSessionForNonInitializeRequests
&& message is not JsonRpcRequest { Method: RequestMethods.Initialize })
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " +
"or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " +
"or enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless if your server doesn't need sessions. " +
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
StatusCodes.Status400BadRequest, requestId: requestId);
return null;
}
return await StartNewSessionAsync(context);
return await StartNewSessionAsync(context, serveStatelessly: IsStatelessOnly);
}
else if (HttpServerTransportOptions.Stateless)
else if (IsStatelessOnly)
{
// In stateless mode, we should not be getting existing sessions via sessionId
// This path should not be reached in stateless mode
@ -491,12 +500,12 @@ internal sealed class StreamableHttpHandler(
return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader);
}
private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context)
private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context, bool serveStatelessly)
{
string sessionId;
StreamableHttpServerTransport transport;
if (!HttpServerTransportOptions.Stateless)
if (!serveStatelessly)
{
sessionId = MakeNewSessionId();
#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally.
@ -525,23 +534,24 @@ internal sealed class StreamableHttpHandler(
};
}
return await CreateSessionAsync(context, transport, sessionId);
return await CreateSessionAsync(context, transport, sessionId, serveStatelessly);
}
private async ValueTask<StreamableHttpSession> CreateSessionAsync(
HttpContext context,
StreamableHttpServerTransport transport,
string sessionId,
bool serveStatelessly,
Action<McpServerOptions>? configureOptions = null)
{
var mcpServerServices = applicationServices;
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null)
if (serveStatelessly || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
if (HttpServerTransportOptions.Stateless)
if (serveStatelessly)
{
// The session does not outlive the request in stateless mode.
mcpServerServices = context.RequestServices;
@ -589,7 +599,7 @@ internal sealed class StreamableHttpHandler(
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
return await CreateSessionAsync(context, transport, sessionId, options =>
return await CreateSessionAsync(context, transport, sessionId, serveStatelessly: false, options =>
{
options.KnownClientInfo = initializeParams.ClientInfo;
options.KnownClientCapabilities = initializeParams.Capabilities;
@ -916,11 +926,13 @@ internal sealed class StreamableHttpHandler(
}
/// <summary>
/// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that
/// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to
/// sessions; we return <see cref="McpErrorCode.UnsupportedProtocolVersion"/> with a supported-versions list
/// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake
/// (SEP-2575).
/// Refuses a 2026-07-28 (or later) request on a fully stateful server
/// (<see cref="HttpServerSessionMode.Stateful"/>). Starting with that revision, Streamable HTTP no longer
/// has sessions (SEP-2567), so it cannot honor the author's opt-in to sessions; we return
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> with a supported-versions list that excludes
/// 2026-07-28 and later. A dual-path client then falls back to the initialize handshake (SEP-2575).
/// <see cref="HttpServerSessionMode.StatefulForInitializeClients"/> serves the request statelessly instead
/// of refusing it.
/// </summary>
private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default)
{
@ -928,8 +940,8 @@ internal sealed class StreamableHttpHandler(
var errorDetail = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " +
"Use the initialize handshake with a protocol version that still supports sessions instead.",
Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateful). " +
"Use the initialize handshake with a protocol version that still supports sessions instead, or set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.StatefulForInitializeClients to serve this version statelessly.",
Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData
{