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:
parent
bba45c44bf
commit
c6a97d94d6
6 changed files with 764 additions and 150 deletions
|
|
@ -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";
|
||||
|
|
|
|||
64
src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs
Normal file
64
src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs
Normal 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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue