Skip to content

Commit c9e8ea9

Browse files
committed
Add decision tree, backpressure warning, and normalize xrefs
Add quick stateless-vs-stateful decision guide and explain why stateless is recommended but not the default. Document the lack of handler backpressure as a deployment footgun for stateful mode. Normalize cross-doc links to use xref instead of relative paths. Also document stale HttpContext risk with SSE transport.
1 parent 1a8bf45 commit c9e8ea9

File tree

6 files changed

+39
-5
lines changed

6 files changed

+39
-5
lines changed

docs/concepts/elicitation/elicitation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici
172172

173173
### URL Elicitation Required Error
174174

175-
When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](../sessions/sessions.md) mode where server-to-client requests are disabled), the server may throw a <xref:ModelContextProtocol.UrlElicitationRequiredException>. This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried.
175+
When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:sessions) mode where server-to-client requests are disabled), the server may throw a <xref:ModelContextProtocol.UrlElicitationRequiredException>. This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried.
176176

177177
#### Throwing UrlElicitationRequiredException on the Server
178178

docs/concepts/httpcontext/httpcontext.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,16 @@ The following code snippet shows the `ContextTools` class accepting an [IHttpCon
2929
and the `GetHttpHeaders` method accessing the current [HttpContext] to retrieve the HTTP headers from the current request.
3030

3131
[!code-csharp[](samples/Tools/ContextTools.cs?name=snippet_AccessHttpContext)]
32+
33+
### SSE transport and stale HttpContext
34+
35+
When using the legacy SSE transport, be aware that the `HttpContext` returned by `IHttpContextAccessor` references the long-lived SSE connection request — not the individual `POST` request that triggered the tool call. This means:
36+
37+
- The `HttpContext.User` may contain stale claims if the client's token was refreshed after the SSE connection was established.
38+
- Request headers, query strings, and other per-request metadata will reflect the initial SSE connection, not the current operation.
39+
40+
The Streamable HTTP transport does not have this issue because each tool call is its own HTTP request, so `IHttpContextAccessor.HttpContext` always reflects the current request. In [stateless](xref:sessions) mode, this is guaranteed since every request creates a fresh server context.
41+
42+
<!-- mlc-disable-next-line -->
43+
> [!NOTE]
44+
> The server validates that the user identity has not changed between the session-initiating request and subsequent requests (using the `sub`, `NameIdentifier`, or `UPN` claim). If the user identity changes, the request is rejected with `403 Forbidden`. However, other claims (roles, permissions, custom claims) are not re-validated and may become stale over the lifetime of a session.

docs/concepts/logging/logging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ MCP servers that implement the Logging utility must declare this in the capabili
4646
[Initialization]: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
4747

4848
Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server
49-
to send log messages&mdash;only allows it. Note that [stateless](../sessions/sessions.md) MCP servers might not be capable of sending log
49+
to send log messages&mdash;only allows it. Note that [stateless](xref:sessions) MCP servers might not be capable of sending log
5050
messages as there might not be an open connection to the client on which the log messages could be sent.
5151

5252
The C# SDK provides an extension method <xref:Microsoft.Extensions.DependencyInjection.McpServerBuilderExtensions.WithSetLoggingLevelHandler*> on <xref:Microsoft.Extensions.DependencyInjection.IMcpServerBuilder> to allow the

docs/concepts/sessions/sessions.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso
1111

1212
[Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
1313

14+
**Quick guide — which mode should I use?**
15+
16+
- Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.**
17+
- Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.**
18+
- Otherwise → **Use stateless** (`options.Stateless = true`).
19+
20+
<!-- mlc-disable-next-line -->
21+
> [!NOTE]
22+
> **Why isn't stateless the default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode.
23+
1424
## Stateless mode (recommended)
1525

1626
Stateless mode is the recommended default for HTTP-based MCP servers. When enabled, the server doesn't track any state between requests, doesn't use the `Mcp-Session-Id` header, and treats each request independently. This is the simplest and most scalable deployment model.
@@ -115,6 +125,12 @@ You can mitigate this with <xref:ModelContextProtocol.AspNetCore.HttpServerTrans
115125

116126
Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode.
117127

128+
#### No built-in backpressure on request handlers
129+
130+
The SDK does not limit how long a handler can run or how many requests can be processed concurrently within a session. A misbehaving or compromised client can flood a stateful session with requests, and each request will spawn a handler that runs to completion. This can lead to thread starvation, GC pressure, or out-of-memory conditions that affect the entire HTTP server process — not just the offending session.
131+
132+
Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response. This means Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally. The <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> and <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> settings help protect against non-malicious overuse (e.g., a buggy client creating too many sessions), but they are not a substitute for HTTP-level protections.
133+
118134
## stdio transport
119135

120136
The [stdio transport](xref:transports) is inherently single-session. The client launches the server as a child process and communicates over stdin/stdout. There is exactly one session per process, the session starts when the process starts, and it ends when the process exits.

docs/concepts/transports/transports.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ app.MapMcp();
131131
app.Run();
132132
```
133133

134-
By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](../sessions/sessions.md) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options.
134+
By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options.
135135

136136
A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter:
137137

@@ -204,6 +204,6 @@ No additional configuration is needed. When a client connects using the SSE prot
204204
| Process model | Child process | Remote HTTP | Remote HTTP |
205205
| Direction | Bidirectional | Bidirectional | Server→client stream + client→server POST |
206206
| Session resumption | N/A | ✓ (stateful mode) ||
207-
| Stateless mode | N/A | ✓ ([recommended](../sessions/sessions.md)) ||
207+
| Stateless mode | N/A | ✓ ([recommended](xref:sessions)) ||
208208
| Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) |
209209
| Best for | Local tools | Remote servers | Legacy compatibility |

tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ public async Task ProgressNotifications_Work_InStatelessMode()
227227
cancellationToken: TestContext.Current.CancellationToken);
228228

229229
// Wait for the progress notification to arrive at the client.
230-
await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
230+
await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
231231

232232
// Let the tool complete now that we've confirmed progress was received.
233233
toolCanComplete.SetResult();
@@ -267,6 +267,11 @@ public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode()
267267
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
268268
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
269269

270+
// Two separate McpClient instances are needed because the X-Tool-Suffix header is set on
271+
// the shared HttpClient before connecting. Each McpClient captures the headers at connect
272+
// time, so changing headers between clients proves ConfigureSessionOptions sees different
273+
// request data on each HTTP request.
274+
270275
// First request with "alpha" — proves ConfigureSessionOptions runs and configures the tool.
271276
HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "alpha");
272277

0 commit comments

Comments
 (0)