Add stateless doc, prefer stateless mode and disable legacy SSE by default (#1468)

This commit is contained in:
Stephen Halter 2026-03-27 13:56:16 -07:00 • committed by GitHub
commit 8eb4023c20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1440 additions and 73 deletions

View file

@ -17,6 +17,11 @@ public class HttpServerTransportOptions
/// Gets or sets an optional asynchronous callback to configure per-session <see cref="McpServerOptions"/>
/// 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.
/// </remarks>
public Func<HttpContext, McpServerOptions, CancellationToken, Task>? ConfigureSessionOptions { get; set; }
/// <summary>
@ -56,6 +61,40 @@ public class HttpServerTransportOptions
/// </remarks>
public bool Stateless { get; set; }
/// <summary>
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
/// for backward compatibility with clients that do not support the Streamable HTTP transport.
/// </summary>
/// <value>
/// <see langword="true"/> to map the legacy SSE endpoints; <see langword="false"/> to disable them. The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// <para>
/// The legacy SSE transport separates request and response channels: clients POST JSON-RPC messages
/// to <c>/message</c> and receive responses through a long-lived GET SSE stream on <c>/sse</c>.
/// Because the POST endpoint returns <c>202 Accepted</c> immediately, there is no HTTP-level
/// backpressure on handler concurrency — unlike Streamable HTTP, where each POST is held open
/// until the handler responds.
/// </para>
/// <para>
/// Use Streamable HTTP instead whenever possible. If you must support legacy SSE clients,
/// enable this property only for completely trusted clients in isolated processes, and apply
/// HTTP rate-limiting middleware and reverse proxy limits to compensate for the lack of
/// 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.
/// </para>
/// <para>
/// This property can also be enabled via the <c>ModelContextProtocol.AspNetCore.EnableLegacySse</c>
/// <see cref="AppContext"/> switch.
/// </para>
/// </remarks>
[Obsolete(Obsoletions.EnableLegacySse_Message, DiagnosticId = Obsoletions.EnableLegacySse_DiagnosticId, UrlFormat = Obsoletions.EnableLegacySse_Url)]
public bool EnableLegacySse { get; set; } =
AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.EnableLegacySse", out var enabled) && enabled;
/// <summary>
/// Gets or sets the event store for resumability support.
/// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header.
@ -114,8 +153,14 @@ public class HttpServerTransportOptions
/// The amount of time the server waits between any active requests before timing out an MCP session. The default is 2 hours.
/// </value>
/// <remarks>
/// <para>
/// This value is checked in the background every 5 seconds. A client trying to resume a session will receive a 404 status code
/// and should restart their session. A client can keep their session open by keeping a GET request open.
/// </para>
/// <para>
/// Legacy SSE sessions (when <see cref="EnableLegacySse"/> is enabled) are not subject to this timeout — their lifetime is
/// tied to the open GET <c>/sse</c> request, and they are removed immediately when the client disconnects.
/// </para>
/// </remarks>
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
@ -126,9 +171,16 @@ public class HttpServerTransportOptions
/// The maximum number of idle sessions to track in memory. The default is 10,000 sessions.
/// </value>
/// <remarks>
/// <para>
/// Past this limit, the server logs a critical error and terminates the oldest idle sessions, even if they have not reached
/// their <see cref="IdleTimeout"/>, until the idle session count is below this limit. Clients that keep their session open by
/// keeping a GET request open don't count towards this limit.
/// their <see cref="IdleTimeout"/>, until the idle session count is below this limit. Sessions with any active HTTP request
/// are not considered idle and don't count towards this limit.
/// </para>
/// <para>
/// Legacy SSE sessions (when <see cref="EnableLegacySse"/> is enabled) are never considered idle because their lifetime is
/// tied to the open GET <c>/sse</c> request. They are not subject to <see cref="IdleTimeout"/> or this limit — they exist
/// exactly as long as the SSE connection is open.
/// </para>
/// </remarks>
public int MaxIdleSessionCount { get; set; } = 10_000;

View file

@ -22,13 +22,24 @@ public static class McpEndpointRouteBuilderExtensions
/// <exception cref="InvalidOperationException">The required MCP services have not been registered. Ensure <see cref="HttpMcpServerBuilderExtensions.WithHttpTransport"/> has been called during application startup.</exception>
/// <remarks>
/// For details about the Streamable HTTP transport, see the <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http">2025-11-25 protocol specification</see>.
/// This method also maps legacy SSE endpoints for backward compatibility at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">2024-11-05 protocol specification</see>.
/// When legacy SSE is enabled via <see cref="HttpServerTransportOptions.EnableLegacySse"/>, this method also maps legacy SSE endpoints at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">2024-11-05 protocol specification</see>.
/// </remarks>
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
{
var streamableHttpHandler = endpoints.ServiceProvider.GetService<StreamableHttpHandler>() ??
throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code.");
var options = streamableHttpHandler.HttpServerTransportOptions;
#pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled
if (options.Stateless && options.EnableLegacySse)
{
throw new InvalidOperationException(
"Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " +
"shared between the GET /sse and POST /message requests. Remove the EnableLegacySse setting or disable stateless mode.");
}
#pragma warning restore MCP9004
var mcpGroup = endpoints.MapGroup(pattern);
var streamableHttpGroup = mcpGroup.MapGroup("")
.WithDisplayName(b => $"MCP Streamable HTTP | {b.DisplayName}")
@ -39,7 +50,7 @@ public static class McpEndpointRouteBuilderExtensions
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
if (!streamableHttpHandler.HttpServerTransportOptions.Stateless)
if (!options.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.
@ -49,17 +60,22 @@ public static class McpEndpointRouteBuilderExtensions
// The DELETE endpoint is not mapped in Stateless mode since there is no server-side state for the DELETE to clean up.
streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync);
// Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests
// will be handled by the same process as the /sse request.
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
var sseGroup = mcpGroup.MapGroup("")
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
#pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled
if (options.EnableLegacySse)
#pragma warning restore MCP9004
{
// Map legacy HTTP with SSE endpoints. These are disabled by default because the SSE transport
// has no built-in request backpressure (POST returns 202 immediately). Enable only for trusted clients.
var sseHandler = endpoints.ServiceProvider.GetRequiredService<SseHandler>();
var sseGroup = mcpGroup.MapGroup("")
.WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}");
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
.WithMetadata(new AcceptsMetadata(["application/json"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync)
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"]));
sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync)
.WithMetadata(new AcceptsMetadata(["application/json"]))
.WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted));
}
}
return mcpGroup;

View file

@ -22,6 +22,7 @@
<ItemGroup>
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
</ItemGroup>
<ItemGroup>

View file

@ -219,7 +219,11 @@ internal sealed class StreamableHttpHandler(
{
if (string.IsNullOrEmpty(sessionId))
{
await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest);
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. " +
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
StatusCodes.Status400BadRequest);
return null;
}
@ -302,7 +306,9 @@ internal sealed class StreamableHttpHandler(
&& 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.",
"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. " +
"See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.",
StatusCodes.Status400BadRequest);
return null;
}