Skip to content

Commit 88bad8b

Browse files
halter73Copilot
andcommitted
Disable legacy SSE endpoints by default (MCP9003)
Legacy SSE endpoints (/sse and /message) are now disabled by default because the SSE transport has no built-in HTTP-level backpressure -- POST returns 202 Accepted immediately without waiting for handler completion. This means default stateful and stateless modes now provide identical backpressure characteristics. To opt in, set HttpServerTransportOptions.EnableLegacySse = true (marked [Obsolete] with MCP9003) or use the AppContext switch ModelContextProtocol.AspNetCore.EnableLegacySse. SSE endpoints remain always disabled in stateless mode regardless of this setting. Update sessions.md, transports.md, and list-of-diagnostics.md to document the change, and migrate HttpTaskIntegrationTests to use Streamable HTTP since they were only incidentally using SSE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b5a47b5 commit 88bad8b

File tree

14 files changed

+161
-46
lines changed

14 files changed

+161
-46
lines changed

docs/concepts/sessions/sessions.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso
1515

1616
- Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.**
1717
- Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.**
18-
- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful.** (Legacy SSE endpoints are disabled in stateless mode.)
18+
- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful** with <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default due to [backpressure concerns](#sse-legacy-1)).
1919
- Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.**
2020
- Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.**
2121
- Otherwise → **Use stateless** (`options.Stateless = true`).
@@ -52,7 +52,7 @@ When <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless>
5252
- <xref:ModelContextProtocol.McpSession.SessionId> is `null`, and the `Mcp-Session-Id` header is not sent or expected
5353
- Each HTTP request creates a fresh server context — no state carries over between requests
5454
- <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> still works, but is called **per HTTP request** rather than once per session (see [Per-request configuration in stateless mode](#per-request-configuration-in-stateless-mode))
55-
- The `GET` and `DELETE` MCP endpoints are not mapped, and the [legacy SSE endpoints](#sse-legacy) (`/sse` and `/message`) are disabled — clients that only support the legacy SSE transport cannot connect
55+
- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#sse-legacy) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect
5656
- **Server-to-client requests are disabled**, including:
5757
- [Sampling](xref:sampling) (`SampleAsync`)
5858
- [Elicitation](xref:elicitation) (`ElicitAsync`)
@@ -101,7 +101,7 @@ This means servers that need user confirmation, LLM reasoning, or other client i
101101

102102
When <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling:
103103

104-
- Server-to-client requests (sampling, elicitation, roots) via an open SSE stream
104+
- Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream
105105
- Unsolicited notifications (resource updates, logging messages)
106106
- Resource subscriptions
107107
- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session)
@@ -113,7 +113,7 @@ Use stateful mode when your server needs one or more of:
113113
- **Server-to-client requests**: Tools that call `ElicitAsync`, `SampleAsync`, or `RequestRootsAsync` to interact with the client
114114
- **Unsolicited notifications**: Sending resource-changed notifications or log messages without a preceding client request
115115
- **Resource subscriptions**: Clients subscribing to resource changes and receiving updates
116-
- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) (the `/sse` and `/message` endpoints are only available in stateful mode)
116+
- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) — requires <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default)
117117
- **Session-scoped state**: Logic that must persist across multiple requests within the same session
118118
- **Concurrent client isolation**: Multiple agents or editor instances connecting simultaneously, where per-client state must not leak between users — separate working environments, independent scratch state, or parallel simulations where each participant needs its own context. The server — not the model — controls when sessions are created, so the harness decides the boundaries of isolation.
119119
- **Local development and debugging**: Testing a typically-stdio server over HTTP where you want to attach a debugger, see log output on stdout, and have editors like Claude Code, GitHub Copilot in VS Code, and Cursor reset the server's state by starting a new session — without requiring a process restart. This closely mirrors the stdio experience where restarting the server process gives the client a clean slate.
@@ -131,7 +131,7 @@ The [deployment considerations](#deployment-considerations) below are real conce
131131
| **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) |
132132
| **Unsolicited notifications** | Not supported | Supported (resource updates, logging) |
133133
| **Resource subscriptions** | Not supported | Supported |
134-
| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients, but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) |
134+
| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) |
135135
| **Local development** | Works, but no way to reset server state from the editor | Editors can reset state by starting a new session without restarting the process |
136136
| **Concurrent client isolation** | No distinction between clients — all requests are independent | Each client gets its own session with isolated state |
137137
| **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate |
@@ -208,7 +208,10 @@ Stateful sessions introduce several challenges for production, internet-facing s
208208

209209
### SSE (legacy)
210210

211-
The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are only mapped when `Stateless = false` (the default), because the GET and POST requests must be handled by the same server process sharing in-memory session state.
211+
The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** because the SSE transport has [no built-in HTTP-level backpressure](#sse-legacy-1). To enable them, set <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`.
212+
213+
> [!NOTE]
214+
> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests.
212215
213216
#### How SSE sessions work
214217

@@ -472,7 +475,7 @@ For task-augmented requests, the specification [requires](https://modelcontextpr
472475

473476
### Request backpressure
474477

475-
How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. The key factor is whether the HTTP POST response stays open while the handler runs — because when it does, HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits how many concurrent handlers a single client connection can drive.
478+
How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. **In the default configuration, stateful and stateless modes provide identical HTTP-level backpressure** — both hold the POST response open while the handler runs, so HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits concurrent handlers per connection. The unbounded cases (legacy SSE, `EventStreamStore`, Tasks) are all **opt-in** advanced features.
476479

477480
#### Default stateful mode (no EventStreamStore, no tasks)
478481

@@ -486,7 +489,9 @@ One difference from gRPC: handler cancellation tokens are linked to the **sessio
486489

487490
For comparison, ASP.NET Core SignalR limits concurrent hub invocations per client to **1** by default (`MaximumParallelInvocationsPerClient`). Default stateful MCP is less restrictive but still bounded by HTTP/2 stream limits.
488491

489-
#### SSE (legacy)
492+
#### SSE (legacy — opt-in only)
493+
494+
Legacy SSE endpoints are [disabled by default](#sse-legacy) and must be explicitly enabled via <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse>. This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure.
490495

491496
The legacy SSE transport separates the request and response channels: clients POST JSON-RPC messages to `/message` and receive responses through a long-lived GET SSE stream on `/sse`. The POST endpoint returns **202 Accepted immediately** after queuing the message — it does not wait for the handler to complete. This means there is **no HTTP-level backpressure** on handler concurrency, because each POST frees its connection immediately regardless of how long the handler runs.
492497

@@ -523,15 +528,15 @@ For servers using the built-in automatic task handlers without external work dis
523528

524529
#### Stateless mode
525530

526-
Stateless mode has the strongest backpressure story. Each handler's lifetime is the HTTP request's lifetime — `McpServer.DisposeAsync()` awaits all in-flight handlers before the POST response completes. This means Kestrel's connection limits, HTTP/2 `MaxStreamsPerConnection`, request timeouts, and rate-limiting middleware all apply naturally — identical to a standard ASP.NET Core minimal API or controller action.
531+
Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above).
527532

528533
#### Summary
529534

530535
| Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection |
531536
|---|---|---|---|
532-
| **Stateless** | Yes (handler = request) | HTTP/2 streams + Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) |
533-
| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams | `MaxStreamsPerConnection` (default: 100) |
534-
| **SSE (legacy)** | No (returns 202 Accepted) | None built-in; GET stream provides cleanup | Unbounded — apply rate limiting |
537+
| **Stateless** | Yes (handler = request) | HTTP/2 streams, Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) |
538+
| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) |
539+
| **SSE (legacy — opt-in)** | No (returns 202 Accepted) | None built-in; GET stream provides cleanup | Unbounded — apply rate limiting |
535540
| **Stateful + EventStreamStore** | No (if `EnablePollingAsync()` called) | None built-in | Unbounded — apply rate limiting |
536541
| **Stateful + Tasks** | No (returns task ID immediately) | None built-in | Unbounded — apply rate limiting |
537542

docs/concepts/transports/transports.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ await using var client = await McpClient.ResumeSessionAsync(transport, new Resum
113113

114114
#### Streamable HTTP server (ASP.NET Core)
115115

116-
Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The <xref:Microsoft.AspNetCore.Builder.McpEndpointRouteBuilderExtensions.MapMcp*> method maps the Streamable HTTP endpoint at the specified route (root by default). It also maps legacy SSE endpoints at `{route}/sse` and `{route}/message` for backward compatibility.
116+
Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The <xref:Microsoft.AspNetCore.Builder.McpEndpointRouteBuilderExtensions.MapMcp*> method maps the Streamable HTTP endpoint at the specified route (root by default).
117117

118118
```csharp
119119
var builder = WebApplication.CreateBuilder(args);
@@ -141,7 +141,7 @@ A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools]
141141
app.MapMcp("/mcp");
142142
```
143143

144-
When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients should connect to `{route}/sse` (e.g., `https://host/mcp/sse`).
144+
When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:sessions#sse-legacy)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`).
145145

146146
### SSE transport (legacy)
147147

@@ -178,7 +178,7 @@ SSE-specific configuration options:
178178

179179
#### SSE server (ASP.NET Core)
180180

181-
The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. The same `MapMcp()` endpoint handles both protocols — clients connecting with SSE are automatically served using the legacy SSE mechanism. SSE requires stateful mode (the default); legacy SSE endpoints are not mapped when `Stateless = true`.
181+
The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](xref:sessions#sse-legacy-1). To enable them, set <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> to `true`. SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`.
182182

183183
```csharp
184184
var builder = WebApplication.CreateBuilder(args);
@@ -188,18 +188,24 @@ builder.Services.AddMcpServer()
188188
{
189189
// SSE requires stateful mode (the default). Set explicitly for forward compatibility.
190190
options.Stateless = false;
191+
192+
#pragma warning disable MCP9003 // EnableLegacySse is obsolete
193+
// Enable legacy SSE endpoints for clients that don't support Streamable HTTP.
194+
// See sessions doc for backpressure implications.
195+
options.EnableLegacySse = true;
196+
#pragma warning restore MCP9003
191197
})
192198
.WithTools<MyTools>();
193199

194200
var app = builder.Build();
195201

196-
// MapMcp() serves both Streamable HTTP and legacy SSE.
197-
// SSE clients connect to /sse (or {route}/sse for custom routes).
202+
// MapMcp() serves Streamable HTTP. Legacy SSE (/sse and /message) is also
203+
// available because EnableLegacySse is set to true above.
198204
app.MapMcp();
199205
app.Run();
200206
```
201207

202-
No additional configuration is needed. When a client connects using the SSE protocol, the server responds with an SSE stream for server-to-client messages and accepts client-to-server messages via a separate POST endpoint.
208+
See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details on SSE session lifetime, configuration, and backpressure implications.
203209

204210
### Transport mode comparison
205211

docs/list-of-diagnostics.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the
3636
| :------------ | :----- | :---------- |
3737
| `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. |
3838
| `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. |
39+
| `MCP9003` | In place | <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details. |

src/Common/Obsoletions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ internal static class Obsoletions
2525

2626
// MCP9002 was used for the AddXxxFilter extension methods on IMcpServerBuilder that were superseded by
2727
// WithMessageFilters() and WithRequestFilters(). The APIs were removed; do not reuse this diagnostic ID.
28+
29+
public const string EnableLegacySse_DiagnosticId = "MCP9003";
30+
public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead.";
31+
public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
2832
}

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,39 @@ public class HttpServerTransportOptions
6161
/// </remarks>
6262
public bool Stateless { get; set; }
6363

64+
/// <summary>
65+
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
66+
/// for backward compatibility with clients that do not support the Streamable HTTP transport.
67+
/// </summary>
68+
/// <value>
69+
/// <see langword="true"/> to map the legacy SSE endpoints; <see langword="false"/> to disable them. The default is <see langword="false"/>.
70+
/// </value>
71+
/// <remarks>
72+
/// <para>
73+
/// The legacy SSE transport separates request and response channels: clients POST JSON-RPC messages
74+
/// to <c>/message</c> and receive responses through a long-lived GET SSE stream on <c>/sse</c>.
75+
/// Because the POST endpoint returns <c>202 Accepted</c> immediately, there is no HTTP-level
76+
/// backpressure on handler concurrency — unlike Streamable HTTP, where each POST is held open
77+
/// until the handler responds.
78+
/// </para>
79+
/// <para>
80+
/// Use Streamable HTTP instead whenever possible. If you must support legacy SSE clients,
81+
/// enable this property only for completely trusted clients in isolated processes, and apply
82+
/// HTTP rate-limiting middleware and reverse proxy limits to compensate for the lack of
83+
/// built-in backpressure.
84+
/// </para>
85+
/// <para>
86+
/// Setting this to <see langword="true"/> while <see cref="Stateless"/> is also <see langword="true"/>
87+
/// throws an <see cref="InvalidOperationException"/> at startup, because SSE requires in-memory session state.
88+
/// </para>
89+
/// <para>
90+
/// This property can also be enabled via the <c>ModelContextProtocol.AspNetCore.EnableLegacySse</c>
91+
/// <see cref="AppContext"/> switch.
92+
/// </para>
93+
/// </remarks>
94+
[Obsolete(Obsoletions.EnableLegacySse_Message, DiagnosticId = Obsoletions.EnableLegacySse_DiagnosticId, UrlFormat = Obsoletions.EnableLegacySse_Url)]
95+
public bool EnableLegacySse { get; set; }
96+
6497
/// <summary>
6598
/// Gets or sets the event store for resumability support.
6699
/// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header.

0 commit comments

Comments
 (0)