Add stateless doc, prefer stateless mode and disable legacy SSE by default (#1468)
This commit is contained in:
parent
49bf17077d
commit
8eb4023c20
4 changed files with 1440 additions and 73 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue