Skip to content

Add sessions doc and prefer stateless mode in docs, samples, and error messages#1468

Open
halter73 wants to merge 37 commits intomainfrom
halter73/stateless-docs
Open

Add sessions doc and prefer stateless mode in docs, samples, and error messages#1468
halter73 wants to merge 37 commits intomainfrom
halter73/stateless-docs

Conversation

@halter73
Copy link
Contributor

Recommend stateless mode as the default for HTTP-based MCP servers across documentation, samples, and error messages.

Docs:

  • Add comprehensive sessions conceptual doc covering stateless (recommended), stateful, and stdio session behaviors
  • Update getting-started, transports, filters, and other conceptual docs to use stateless mode in examples
  • Add Sampling to docs table of contents
  • Clarify ConfigureSessionOptions runs per-request in stateless mode

Samples:

  • Convert ProtectedMcpServer to stateless mode
  • Add comments to AspNetCoreMcpServer and EverythingServer explaining why they require sessions

Error messages:

  • Improve missing Mcp-Session-Id errors to suggest stateless mode and link to session documentation

Tests:

  • Add tests for progress notifications and ConfigureSessionOptions in stateless mode
  • Verify error messages reference stateless mode guidance

…r messages

Recommend stateless mode as the default for HTTP-based MCP servers
across documentation, samples, and error messages.

Docs:
- Add comprehensive sessions conceptual doc covering stateless
  (recommended), stateful, and stdio session behaviors
- Update getting-started, transports, filters, and other conceptual
  docs to use stateless mode in examples
- Add Sampling to docs table of contents
- Clarify ConfigureSessionOptions runs per-request in stateless mode

Samples:
- Convert ProtectedMcpServer to stateless mode
- Add comments to AspNetCoreMcpServer and EverythingServer explaining
  why they require sessions

Error messages:
- Improve missing Mcp-Session-Id errors to suggest stateless mode and
  link to session documentation

Tests:
- Add tests for progress notifications and ConfigureSessionOptions in
  stateless mode
- Verify error messages reference stateless mode guidance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the SDK’s guidance around HTTP server sessions by adding a dedicated “Sessions” conceptual doc, shifting most docs/samples to recommend stateless mode by default, and improving HTTP error messages to point users at stateless mode when Mcp-Session-Id is missing.

Changes:

  • Added a comprehensive Sessions conceptual doc (stateless vs. stateful vs. stdio) and updated docs TOC.
  • Updated multiple docs and samples to prefer HttpServerTransportOptions.Stateless = true by default, with notes on when stateful sessions are required.
  • Improved Streamable HTTP missing-session error messages and added/updated tests to validate the guidance appears.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs Adds assertions that missing-session 400s include stateless guidance.
tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs Adds stateless-mode tests for progress notifications and ConfigureSessionOptions behavior.
src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs Enhances 400 error messages to recommend stateless mode and link to sessions docs.
src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs Clarifies ConfigureSessionOptions invocation semantics in stateless mode.
samples/ProtectedMcpServer/Program.cs Switches sample to stateless mode with explanatory comments.
samples/EverythingServer/Program.cs Adds explanation why the sample must remain stateful.
samples/AspNetCoreMcpServer/Program.cs Adds explanation why the sample must remain stateful.
docs/concepts/transports/transports.md Updates examples to stateless mode and adds stateless guidance in narrative/table.
docs/concepts/toc.yml Adds Sessions and Sampling entries to conceptual TOC.
docs/concepts/sessions/sessions.md New comprehensive sessions documentation page.
docs/concepts/progress/samples/server/Program.cs Updates example to stateless mode.
docs/concepts/logging/logging.md Links stateless behavior to sessions doc.
docs/concepts/index.md Adds Sessions to concepts index table.
docs/concepts/httpcontext/samples/Program.cs Updates example to stateless mode.
docs/concepts/getting-started.md Updates getting-started HTTP server snippet to stateless mode.
docs/concepts/filters.md Updates auth/filters example to stateless mode.
docs/concepts/elicitation/elicitation.md Links stateless limitation note to sessions doc.

halter73 and others added 2 commits March 25, 2026 18:16
- Fix relative links to sessions doc from subdirectories
- Fix doc URLs in error messages to use .html extension
- Strengthen ConfigureSessionOptions test with two requests proving
  per-request behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TokenProgress.Report() uses fire-and-forget (no await), so in
stateless mode the SSE stream can close before notifications flush.
Rewrite the test using TCS coordination: the tool reports progress
then waits, giving the notification time to flush before the stream
closes. A SynchronousProgress<T> helper avoids the thread pool
posting race inherent to Progress<T>.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

## Stateless mode (recommended)

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to consider a breaking behavioral change to change the default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite possibly, but not yet especially considering how breaking it will be and now there isn't an alternative for elicitation/sampling/roots like MRTR even if the draft spec yet. The fix for the break is simply to manually configure Stateless = false which at least isn't too bad.

I updated the PR to use Stateless = false in samples and docs where statefulness currently is being relied on, so I think this PR makes the breaking change a lot easier in the future. That way, anyone copying code from here on out won't be broken by the changed default in the future.

Copy link
Contributor Author

@halter73 halter73 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this note regarding the default for now:

Note

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 (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 or similar mechanisms bring server-to-client interactions to stateless mode.

And also, this:

Warning

Stateful sessions are not safe for public internet deployments without additional hardening. 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 server process — not just the offending session.

Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response, so Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally.

If you must deploy a stateful server to the public internet, consider process-level isolation (e.g., one process or container per user/session) so that a single abusive session cannot starve the entire service. 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.

I think I'm done polishing this for now if you don't mind reapproving.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, I added an immediately obsolete and disabled by default EnableLegacySse option to HttpServerTransportOptions (also app context switch for back compat) I think this gives us the cleanest upgrade path. Hopefully there aren't too many people left relying on the legacy SSE endpoints. Having a seperate option for it makes sense, because the back pressure story is different enough from normal stateful Streamable HTTP (which is largely equivalent to stateless Streamable HTTP with regard to back pressure). There is no back pressure story for legacy SSE basically.

stephentoub
stephentoub previously approved these changes Mar 26, 2026
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.
halter73 and others added 17 commits March 26, 2026 10:08
Every WithHttpTransport() call in samples and docs now explicitly sets
Stateless = true or Stateless = false. This prepares for a potential
future default change and makes the intent clear in code users may copy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add 'Service lifetimes and DI scopes' section to sessions.md covering
how ScopeRequests controls per-handler scoping in stateful HTTP, how
stateless HTTP reuses ASP.NET Core's request scope, and how stdio
defaults to per-handler scoping but is configurable. Includes summary
table and cross-link from the stdio section.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Group sections by purpose: mode selection (stateless/stateful/comparison),
transport details (HTTP lifecycle, deployment considerations, stdio),
server configuration (options, ConfigureSessionOptions, DI scopes),
security (user binding), and advanced features (migration, resumability).

Move comparison table near the decision tree. Move deployment footguns
under HTTP transport. Move stateless trade-offs into the stateless
section. Combine 'When to use stateful' and 'When stateful shines'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Any active HTTP request (POST or GET) prevents a session from being
counted as idle, not just GET/SSE. Fix docs and API comment on
MaxIdleSessionCount. Also remove redundant 'async' from 'async scope'
in DI documentation since nearly all ASP.NET Core scopes are async.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split Streamable HTTP into stateless and stateful columns, fix SSE
server example that incorrectly showed Stateless = true (SSE endpoints
are not mapped in stateless mode), and add cross-reference to sessions
doc.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 4 commits March 26, 2026 14:57
Cover why SSE requires stateful mode, the query string session ID
mechanism, connection-bound session lifetime via HttpContext.RequestAborted,
and clarify that idle timeout, max idle count, and activity tracking
are Streamable HTTP specific.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document per-transport handler cancellation tokens, client-initiated
cancellation via notifications/cancelled, McpServer disposal guarantees
(awaits in-flight handlers), graceful ASP.NET Core shutdown behavior,
stdio process lifecycle, and stateless per-request logging. Add
cross-reference from transports.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move Security up after Server configuration. Promote DI scopes to
its own top-level section. Cancellation/disposal and Advanced features
stay at the bottom.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 2 commits March 26, 2026 15:41
Cover how tasks work in stateless vs stateful mode, the tasks/cancel
vs notifications/cancelled distinction, session-scoped task isolation,
and OpenTelemetry integration (mcp.session.id tag, session/operation
duration histograms, distributed tracing via _meta).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 6 commits March 26, 2026 16:56
Explain that handler CTS is session-scoped (not request-scoped) in
stateful mode, making this a standard persistent-connection concern
rather than an MCP-specific safety issue. Clarify that stateless mode
avoids this because DisposeAsync awaits handlers within the HTTP
request lifetime. Recommend standard HTTP protections alongside
process isolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the prominent WARNING callout with a nuanced 'Request
backpressure' section that explains how each configuration is actually
protected:

- Default stateful: POST held open until handler responds, bounded by
  HTTP/2 MaxStreamsPerConnection (100) — same model as gRPC unary
- EventStreamStore: advanced opt-in that frees POST early via
  EnablePollingAsync, removing HTTP-level backpressure
- Tasks (experimental): fire-and-forget Task.Run returns task ID
  immediately, no HTTP backpressure on handlers
- Stateless: handler lifetime = request lifetime, best protection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SSE POST to /message returns 202 immediately, so handlers have no
HTTP-level backpressure — same fire-and-forget dispatch pattern as
all other modes. The GET stream provides handler cancellation on
disconnect (cleanup) but not concurrency limiting. Note the SignalR
parallel: both have connection-bound session lifetime, but SignalR
also has MaximumParallelInvocationsPerClient (default: 1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
IIS and HTTP.sys also enforce MaxStreamsPerConnection and request
timeouts, so the backpressure discussion should not be Kestrel-specific.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
stephentoub
stephentoub previously approved these changes Mar 27, 2026
Copy link
Contributor

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanics of the change LGTM. For the behavioral breaking change, do we have any sense of the blast radius? Would we want to change the stateless default at the same time so as to minimize the number of releases with behavioral breaks? It'd be good to get Jeff to sign off as well.

/// </summary>
public static class McpEndpointRouteBuilderExtensions
{
private static bool EnableLegacySseSwitch { get; } =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking this separately, could EnableLegacySse just be initialized to this?

/// <see cref="AppContext"/> switch.
/// </para>
/// </remarks>
[Obsolete(Obsoletions.EnableLegacySse_Message, DiagnosticId = Obsoletions.EnableLegacySse_DiagnosticId, UrlFormat = Obsoletions.EnableLegacySse_Url)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introducing it as obsolete out of the gate because you expect we'll want to remove SSE support entirely at some point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Eventually, hopefully soon, we can move everyone off of it. This is the start of that.

Clarify that legacy SSE sessions are not subject to IdleTimeout or
MaxIdleSessionCount — their lifetime is tied to the GET /sse request.
Add backpressure remark to SseResponseStreamTransport warning callers
about the lack of HTTP-level backpressure when POST returns immediately.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73
Copy link
Contributor Author

For the behavioral breaking change, do we have any sense of the blast radius? Would we want to change the stateless default at the same time so as to minimize the number of releases with behavioral breaks?

It's really hard to say. Long term, I do want to default to stateless. Particularly once MRTR lands outside some temporary back compat scenarios. I was hoping that this will be the less of the two behavior breaks we're setting up. And I think the breaking change announcement should tell people to pick a stateful or stateless mode explicitly based on their needs, so we don't break them if/when we do change the default to stateless.

Add client-side session behavior documentation covering session lifecycle,
expiry detection, reconnection patterns, and transport options. Move Sessions
to Base Protocol in toc.yml. Add session cross-references to sampling, roots,
tools, prompts, progress, and cancellation docs.

Restructure sessions.md: merge redundant stateless sections, promote Tasks,
Request backpressure, and Observability to top-level sections, move client
section before Advanced features, and fold stream reconnection into lifecycle.

Add reconnection integration test (Client_CanReconnect_AfterSessionExpiry)
using MapMcp with middleware to simulate 404 session expiry.

Clarify the two session ID concepts: transport session ID (McpSession.SessionId,
shared between client and server) vs telemetry session ID (mcp.session.id tag,
per-instance). Document that middleware should read Mcp-Session-Id from the
response header after await next() to capture it on the initialize request.

Initialize EnableLegacySse from AppContext switch in property initializer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 force-pushed the halter73/stateless-docs branch from 0454970 to b7e68dc Compare March 27, 2026 08:40
halter73 and others added 2 commits March 27, 2026 01:49
…vior

Rewrite sessions.md intro to lead with the Stateless property recommendation,
clarify that sessions enabled is the current C# SDK default (not a protocol
requirement), and note the spec requires clients use sessions when servers
request them. Replace middleware example with minimal API endpoint filter.
Fix AllowNewSessionForNonInitializeRequests docs to call out spec non-compliance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restructure document flow: move client-side behavior up, fold security
into server configuration, move legacy SSE to its own section near the
end. Replace middleware example with minimal API endpoint filter using
Activity.AddTag for the transport session ID. Migrate SSE anchors
across transports.md, list-of-diagnostics.md, and filters.md.

Fix endpoint filter test to avoid strict request count assertion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@stephentoub
Copy link
Contributor

Any way we could add an analyzer to help mitigate the breaks? eg detect that Stateless = false isn't being used anywhere and then warn on any use of the problematic methods?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants