Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
869 lines
78 KiB
Markdown
869 lines
78 KiB
Markdown
---
|
||
title: Stateless and stateful mode
|
||
author: halter73
|
||
description: When to use stateless vs. stateful mode in the MCP C# SDK, server-side session management, client-side session lifecycle, and distributed tracing.
|
||
uid: stateless
|
||
---
|
||
|
||
# Stateless and stateful mode
|
||
|
||
The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMode> to <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.Stateless>**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr).
|
||
|
||
When sessions are enabled (`SessionMode = HttpServerSessionMode.Stateful`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)).
|
||
|
||
[Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
||
|
||
**Quick guide — which mode should I use?**
|
||
|
||
- Does your server need to send requests _to_ the client (elicitation, sampling, roots) and can't rely on clients supporting [MRTR](xref:mrtr)? → **Use stateful.**
|
||
- Does your server send [unsolicited notifications](#how-streamable-http-delivers-messages) or support resource subscriptions? → **Use stateful.**
|
||
- Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default due to [backpressure concerns](#request-backpressure)).
|
||
- Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.**
|
||
- Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.**
|
||
- Does your server need sessions for existing clients but must also serve `2026-07-28` clients natively on the same endpoint? → **Use [hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only)** (`options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients`).
|
||
- Otherwise → **Use stateless** (`options.SessionMode = HttpServerSessionMode.Stateless`).
|
||
|
||
<!-- mlc-disable-next-line -->
|
||
> [!NOTE]
|
||
> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMode> to <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.Stateless> to match the new wire format. You can still opt back into sessions with `SessionMode = HttpServerSessionMode.Stateful` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions).
|
||
|
||
## Forward and backward compatibility
|
||
|
||
The `SessionMode` property is the single most important setting for forward-proofing your MCP server. The default is now <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.Stateless> (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `SessionMode` explicitly rather than relying on the default:
|
||
|
||
- **`HttpServerSessionMode.Stateless`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this configuration lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today.
|
||
|
||
- **`HttpServerSessionMode.Stateful`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that in this mode a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision.
|
||
|
||
- **`HttpServerSessionMode.StatefulForInitializeClients`** — the migration mode. `initialize`-handshake clients get full sessions while `2026-07-28` and later clients are served statelessly on the same endpoint, so you can adopt the new revision progressively instead of waiting for every client to migrate. See [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only).
|
||
|
||
<!-- mlc-disable-next-line -->
|
||
> [!TIP]
|
||
> If you're not sure which to pick, leave the default (`HttpServerSessionMode.Stateless`). You can switch to `HttpServerSessionMode.Stateful` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated.
|
||
|
||
<!-- mlc-disable-next-line -->
|
||
> [!NOTE]
|
||
> The `bool` <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> property remains a convenient shorthand for the two most common modes: assigning `true` selects `Stateless` and assigning `false` selects `Stateful`, while reading it returns `true` only for `Stateless`. Use `SessionMode` when you need `StatefulForInitializeClients`. Both properties update the same underlying value, so the last assignment wins.
|
||
|
||
### The 2026-07-28 protocol revision
|
||
|
||
The `2026-07-28` protocol revision goes further than stateless mode: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport).
|
||
|
||
**Server side.** With `SessionMode = HttpServerSessionMode.Stateless` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `SessionMode = HttpServerSessionMode.Stateful`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. `SessionMode = HttpServerSessionMode.StatefulForInitializeClients` opts out of that downgrade and serves the `2026-07-28` request statelessly instead — see [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request, in every mode.
|
||
|
||
**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients.
|
||
|
||
**Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it:
|
||
|
||
- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint.
|
||
- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process.
|
||
|
||
The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect.
|
||
|
||
**Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an <xref:ModelContextProtocol.McpException> instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value.
|
||
|
||
```csharp
|
||
var clientOptions = new McpClientOptions
|
||
{
|
||
ProtocolVersion = "2026-07-28",
|
||
};
|
||
```
|
||
|
||
### Migrating from legacy SSE
|
||
|
||
If your clients connect to a `/sse` endpoint (for example, `https://my-server.example.com/sse`), they are using the [legacy SSE transport](#legacy-sse-transport) — regardless of any `Stateless` or session settings on the server. The `/sse` and `/message` endpoints are now **disabled by default** (<xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> is `false` and marked `[Obsolete]` with diagnostic `MCP9004`). Upgrading the server SDK without updating clients will break SSE connections.
|
||
|
||
**Client-side migration.** Change the client `Endpoint` from the `/sse` path to the root MCP endpoint — the same URL your server passes to `MapMcp()`. For example:
|
||
|
||
```csharp
|
||
// Before (legacy SSE):
|
||
Endpoint = new Uri("https://my-server.example.com/sse")
|
||
|
||
// After (Streamable HTTP):
|
||
Endpoint = new Uri("https://my-server.example.com/")
|
||
```
|
||
|
||
With the default <xref:ModelContextProtocol.Client.HttpTransportMode.AutoDetect> transport mode, the client automatically tries Streamable HTTP first. You can also set `TransportMode = HttpTransportMode.StreamableHttp` explicitly if you know the server supports it.
|
||
|
||
**Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`.
|
||
|
||
**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `SessionMode = HttpServerSessionMode.Stateful`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `SessionMode = HttpServerSessionMode.Stateless`.
|
||
|
||
## 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.
|
||
|
||
### Enabling stateless mode
|
||
|
||
```csharp
|
||
using ModelContextProtocol.AspNetCore;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
options.SessionMode = HttpServerSessionMode.Stateless;
|
||
})
|
||
.WithTools<MyTools>();
|
||
|
||
var app = builder.Build();
|
||
app.MapMcp();
|
||
app.Run();
|
||
```
|
||
|
||
### What stateless mode changes
|
||
|
||
When <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMode> is <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.Stateless>:
|
||
|
||
- <xref:ModelContextProtocol.McpSession.SessionId> is `null`, and the `Mcp-Session-Id` header is not sent or expected
|
||
- Each HTTP request creates a fresh server context — no state carries over between requests
|
||
- <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))
|
||
- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#legacy-sse-transport) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport can't connect
|
||
- **Server-to-client requests are disabled**, including:
|
||
- [Sampling](xref:sampling) (`SampleAsync`)
|
||
- [Elicitation](xref:elicitation) (`ElicitAsync`)
|
||
- [Roots](xref:roots) (`RequestRootsAsync`)
|
||
- Ping — the server can't ping the client to verify connectivity
|
||
|
||
[MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak `2026-07-28` — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions).
|
||
- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (for example, resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client `POST` request — to understand why, see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages).
|
||
- **No concurrent client isolation.** Every request is independent — the server can't distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client.
|
||
- **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means.
|
||
- [Tasks](xref:tasks) **are supported** — the task store is shared across ephemeral server instances. However, task-augmented sampling and elicitation are disabled because they require server-to-client requests.
|
||
|
||
These restrictions exist because in a stateless deployment, responses from the client could arrive at any server instance — not necessarily the one that sent the request.
|
||
|
||
### When to use stateless mode
|
||
|
||
Use stateless mode when your server:
|
||
|
||
- Exposes tools that are pure functions (take input, return output)
|
||
- Doesn't need to ask the client for user input (elicitation) or LLM completions (sampling)
|
||
- Doesn't need to send unsolicited notifications to the client
|
||
- Needs to scale horizontally behind a load balancer without session affinity
|
||
- Is deployed to serverless environments (Azure Functions, AWS Lambda, etc.)
|
||
|
||
Most MCP servers fall into this category. Tools that call APIs, query databases, process data, or return computed results are all natural fits for stateless mode. For guidance on choosing between stateless and stateful mode, see [Forward and backward compatibility](#forward-and-backward-compatibility).
|
||
|
||
### Stateless alternatives for server-to-client interactions
|
||
|
||
The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a stateless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached.
|
||
|
||
This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Keep a session if you must interact with clients that do not support `2026-07-28`.
|
||
|
||
## Stateful mode (sessions)
|
||
|
||
When <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMode> is <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.Stateful>, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling:
|
||
|
||
- Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream
|
||
- [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the `GET` stream
|
||
- Resource subscriptions
|
||
- Session-scoped state (for example, `RunSessionHandler`, state that persists across multiple requests within a session)
|
||
|
||
### When to use stateful mode
|
||
|
||
Use stateful mode when your server needs one or more of:
|
||
|
||
- **Server-to-client requests**: Tools that call `ElicitAsync`, `SampleAsync`, or `RequestRootsAsync` to interact with the client
|
||
- **[Unsolicited notifications](#how-streamable-http-delivers-messages)**: Sending resource-changed notifications or log messages outside the context of any active request handler — these require the [GET stream](#how-streamable-http-delivers-messages)
|
||
- **Resource subscriptions**: Clients subscribing to resource changes and receiving updates
|
||
- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#legacy-sse-transport) — requires <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default)
|
||
- **Session-scoped state**: Logic that must persist across multiple requests within the same session
|
||
- **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.
|
||
- **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.
|
||
|
||
The [deployment considerations](#deployment-considerations) section lists real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set.
|
||
|
||
## Hybrid mode (sessions for initialize clients only)
|
||
|
||
<xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode.StatefulForInitializeClients> serves both eras on a single endpoint: clients that send the `initialize` handshake (`2025-11-25` and earlier) get a full stateful session, while clients using `2026-07-28` and later are served statelessly, per request. It exists so an existing stateful server can adopt the new protocol revision progressively instead of waiting for every client to migrate first.
|
||
|
||
```csharp
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients;
|
||
})
|
||
.WithTools<MyTools>();
|
||
```
|
||
|
||
### What each client sees
|
||
|
||
| Client | Behavior |
|
||
|---|---|
|
||
| `2025-11-25` and earlier (sends `initialize`) | Full stateful session, `Mcp-Session-Id` issued and echoed, `GET` and `DELETE` available, server-to-client requests supported |
|
||
| `2026-07-28` and later (sends `server/discover`) | Served per request with no session ID minted or echoed; `GET` and `DELETE` return `405 Method Not Allowed`; no downgrade to `initialize` |
|
||
|
||
Without hybrid mode, `HttpServerSessionMode.Stateful` refuses a `2026-07-28` request with `-32022 UnsupportedProtocolVersion` so that dual-path clients fall back to `initialize`. Hybrid mode removes that refusal for clients that don't want (or can't perform) the downgrade.
|
||
|
||
### What stays unavailable to `2026-07-28` clients
|
||
|
||
A `2026-07-28` request has no session even on a hybrid endpoint, so all of the [stateless mode restrictions](#what-stateless-mode-changes) still apply to it: no [unsolicited notifications](#how-streamable-http-delivers-messages), no resource subscriptions, no server-initiated ping, and no per-client isolation. Use [MRTR](xref:mrtr) for elicitation (and the deprecated sampling and roots) on that half of the endpoint. Legacy sessions on the same endpoint keep all of those features.
|
||
|
||
### Lifetimes
|
||
|
||
Because the effective mode is decided per request, DI and callback lifetimes follow the request rather than the endpoint:
|
||
|
||
- `2026-07-28` requests resolve services from `HttpContext.RequestServices` with request scoping disabled, exactly like [stateless HTTP](#stateless-http).
|
||
- `initialize`-handshake sessions resolve services from the application provider and scope each request, exactly like [stateful HTTP](#stateful-http).
|
||
- <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> runs once per session for `initialize`-handshake clients and once per HTTP request for `2026-07-28` clients.
|
||
|
||
Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandler`, and so on) continue to govern the session half of the endpoint and are ignored for `2026-07-28` requests.
|
||
|
||
## Comparison
|
||
|
||
| Consideration | Stateless | Stateful |
|
||
|---------------|-----------|----------|
|
||
| **Deployment** | Any topology — load balancer, serverless, multi-instance | Requires session affinity (sticky sessions) |
|
||
| **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing |
|
||
| **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize |
|
||
| **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) |
|
||
| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (`2026-07-28`-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) |
|
||
| **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) |
|
||
| **Resource subscriptions** | Not supported | Supported |
|
||
| **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 [might not send `Mcp-Session-Id` correctly](#deployment-considerations) |
|
||
| **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 |
|
||
| **Concurrent client isolation** | No distinction between clients — all requests are independent | Each client gets its own session with isolated state |
|
||
| **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate |
|
||
| **[Tasks](xref:tasks)** | Supported — shared task store, no per-session isolation | Supported — task store scoped per session |
|
||
|
||
> [!NOTE]
|
||
> [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only) doesn't add a third column: each request follows the **Stateless** column when the client negotiated `2026-07-28` and the **Stateful** column when the client used the `initialize` handshake.
|
||
|
||
## Transports and sessions
|
||
|
||
### Streamable HTTP
|
||
|
||
#### How Streamable HTTP delivers messages
|
||
|
||
Understanding how messages flow between client and server over HTTP is key to understanding why sessions exist and when you can avoid them.
|
||
|
||
**POST response streams (solicited messages).** Every JSON-RPC request from the client arrives as an HTTP POST. The server holds the `POST` response body open as a [Server-Sent Events (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream and writes messages back to it: the JSON-RPC response, any intermediate messages the handler produces (progress notifications, log messages), and — critically — any **server-to-client requests** the handler makes during execution, such as sampling, elicitation, or roots requests. This is a **solicited** interaction: the client's `POST` request solicited the server's response, and the server writes everything related to that request into the same HTTP response body. The `POST` response completes when the final JSON-RPC response is sent.
|
||
|
||
**The `GET` stream (unsolicited messages).** The client can optionally open a long-lived HTTP `GET` request to the same MCP endpoint. This stream is the **only** channel for **unsolicited** messages — notifications or server-to-client requests that the server initiates _outside the context of any active request handler_. For example:
|
||
|
||
- A resource-changed notification fired by a background file watcher
|
||
- A log message emitted asynchronously after all request handlers have returned
|
||
- A server-to-client request that isn't triggered by a tool call
|
||
|
||
These messages are "unsolicited" because no client `POST` solicited them. There is no `POST` response body to write them to — because outside of `POST` requests that solicit the server 1:1 with a JSON-RPC request, there is simply no HTTP response body stream available. The `GET` stream fills this gap.
|
||
|
||
**No `GET` stream = messages silently dropped.** Clients are not required to open a `GET` stream. If the client hasn't opened one, the server has no delivery path for unsolicited messages and silently drops them. This is by design in the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) — unsolicited messages are best-effort.
|
||
|
||
**Why stateless mode can't support unsolicited messages.** In stateless mode, the `GET` endpoint is not mapped at all. Every message the server sends must be part of a `POST` response — there is no other HTTP response body to write to. This is also why server-to-client requests (sampling, elicitation, roots) are disabled: the server could initiate a request down the `POST` response stream during a handler, but the client's response to that request would arrive as a _new_ `POST` — which in stateless mode creates a completely independent server context with no connection to the original handler. The server has no way to correlate the client's reply with the handler that asked the question. Sessions solve this by keeping the handler alive across multiple HTTP round-trips within the same in-memory session.
|
||
|
||
#### Session lifecycle
|
||
|
||
A session begins when a client sends an `initialize` JSON-RPC request without an `Mcp-Session-Id` header. The server:
|
||
|
||
1. Creates a new session with a unique session ID.
|
||
2. Calls <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> (if configured) to customize the session's `McpServerOptions`.
|
||
3. Starts the MCP server for the session.
|
||
4. Returns the session ID in the `Mcp-Session-Id` response header along with the `InitializeResult`.
|
||
|
||
All subsequent requests from the client must include this session ID.
|
||
|
||
#### Activity tracking
|
||
|
||
The server tracks the last activity time for each Streamable HTTP session. Activity is recorded when:
|
||
|
||
- A request arrives for the session (`POST` or `GET`)
|
||
- A response is sent for the session
|
||
|
||
#### Idle timeout
|
||
|
||
Streamable HTTP sessions that have no activity for the duration of <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds.
|
||
|
||
A client can keep its session alive by maintaining any open HTTP request (for example, a long-running `POST` with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle.
|
||
|
||
When a session times out:
|
||
|
||
- The session's `McpServer` is disposed
|
||
- Any pending requests receive cancellation
|
||
- A client that tries to use the expired session ID receives a `404 Session not found` error and should start a new session
|
||
|
||
You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though this is not recommended for production deployments.
|
||
|
||
#### Maximum idle session count
|
||
|
||
<xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> (default: **10,000**) limits how many idle Streamable HTTP sessions can exist simultaneously. If this limit is exceeded:
|
||
|
||
- A critical error is logged
|
||
- The oldest idle sessions are terminated (even if they haven't reached their idle timeout)
|
||
- Termination continues until the idle count is back below the limit
|
||
|
||
Sessions with any active HTTP request don't count toward this limit.
|
||
|
||
#### Termination
|
||
|
||
Streamable HTTP sessions can be terminated by:
|
||
|
||
- **Client DELETE request**: The client sends an HTTP `DELETE` to the session endpoint with its `Mcp-Session-Id`.
|
||
- **Idle timeout**: The session exceeds the idle timeout without activity.
|
||
- **Max idle count**: The server exceeds its maximum idle session count and prunes the oldest sessions.
|
||
- **Server shutdown**: All sessions are disposed when the server shuts down.
|
||
|
||
#### Deployment considerations
|
||
|
||
Stateful sessions introduce several challenges for production, internet-facing services:
|
||
|
||
**Session affinity required.** All requests for a given session must reach the same server instance, because sessions live in memory. If you deploy behind a load balancer, you must configure session affinity (sticky sessions) to route requests to the correct instance. Without session affinity, clients will receive `404 Session not found` errors.
|
||
|
||
**Memory consumption.** Each session consumes memory on the server for the lifetime of the session. The default idle timeout is **2 hours**, and the default maximum idle session count is **10,000**. A server with many concurrent clients can accumulate significant memory usage. Monitor your idle session count and tune <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> and <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> to match your workload.
|
||
|
||
**Server restarts lose all sessions.** Sessions are stored in memory by default. When the server restarts (for deployments, crashes, or scaling events), all sessions are lost. Clients must reinitialize their sessions, which some clients might not handle gracefully. You can mitigate this with <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMigrationHandler>, but this adds complexity. See [Session migration](#session-migration) for details.
|
||
|
||
**Clients that don't send Mcp-Session-Id.** Some MCP clients might 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.
|
||
|
||
**No built-in backpressure on advanced features.** By default, each JSON-RPC request holds its HTTP `POST` open until the handler responds — providing natural HTTP/2 backpressure. However, advanced features like <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EventStreamStore> and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations.
|
||
|
||
### stdio transport
|
||
|
||
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.
|
||
|
||
Because there is only one connection, stdio servers don't need session IDs or any explicit session management. The session is implicit in the process boundary. This makes stdio the simplest transport to use, and it naturally supports all server-to-client features (sampling, elicitation, roots) because there is always exactly one client connected.
|
||
|
||
However, stdio servers cannot be shared between multiple clients. Each client needs its own server process. This is fine for local tool integrations (IDEs, CLI tools) but not suitable for remote or multi-tenant scenarios — use [Streamable HTTP](xref:transports) for those. For details on how DI scopes work with stdio, see [Service lifetimes and DI scopes](#service-lifetimes-and-di-scopes).
|
||
|
||
## Client-side session behavior
|
||
|
||
The SDK's MCP client (<xref:ModelContextProtocol.Client.McpClient>) participates in sessions automatically. The **server controls session creation and destruction** — the client has no say in when a session ends. This section describes how the client manages session state, detects failures, and reconnects.
|
||
|
||
### Session lifecycle
|
||
|
||
#### Joining a session
|
||
|
||
When you call <xref:ModelContextProtocol.Client.McpClient.CreateAsync*>, the client:
|
||
|
||
1. Connects to the server via the configured transport.
|
||
2. Sends an `initialize` JSON-RPC request (without an `Mcp-Session-Id` header).
|
||
3. Receives the server's `InitializeResult` — if the response includes an `Mcp-Session-Id` header, the client stores it.
|
||
4. Automatically includes the session ID in all subsequent requests (POST, GET, DELETE).
|
||
|
||
This is entirely automatic — you don't need to manage the session ID yourself. The <xref:ModelContextProtocol.McpSession.SessionId> property exposes the current session ID (or `null` for transports that don't support sessions, like stdio).
|
||
|
||
#### Session expiry
|
||
|
||
The server can terminate a session at any time — due to idle timeout, max session count exceeded, explicit shutdown, or any server-side policy. When this happens, subsequent requests with that session ID receive HTTP `404`. The client detects this and:
|
||
|
||
1. Wraps the failure in a <xref:ModelContextProtocol.Client.ClientTransportClosedException> with <xref:ModelContextProtocol.Client.HttpClientCompletionDetails> containing the HTTP status code.
|
||
2. Cancels all in-flight operations.
|
||
3. Completes the <xref:ModelContextProtocol.Client.McpClient.Completion> task.
|
||
|
||
**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with <xref:ModelContextProtocol.Client.McpClient.CreateAsync*>, or attempt to resume the existing session with <xref:ModelContextProtocol.Client.McpClient.ResumeSessionAsync*> if the server supports it.
|
||
|
||
The following example demonstrates how to detect session expiry and reconnect:
|
||
|
||
```csharp
|
||
async Task<McpClient> ConnectWithRetryAsync(
|
||
HttpClientTransportOptions transportOptions,
|
||
HttpClient httpClient,
|
||
ILoggerFactory? loggerFactory = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
while (/* app-specific retry condition */)
|
||
{
|
||
await using var transport = new HttpClientTransport(transportOptions, httpClient, loggerFactory);
|
||
var client = await McpClient.CreateAsync(transport, loggerFactory: loggerFactory, cancellationToken: cancellationToken);
|
||
|
||
// Wait for the session to end — this could be graceful disposal or server-side expiry.
|
||
var details = await client.Completion.WaitAsync(cancellationToken);
|
||
|
||
if (details is HttpClientCompletionDetails { HttpStatusCode: System.Net.HttpStatusCode.NotFound })
|
||
{
|
||
// The server expired our session. Create a new one.
|
||
loggerFactory?.CreateLogger("Reconnect").LogInformation(
|
||
"Session expired (404). Reconnecting with a new session...");
|
||
continue;
|
||
}
|
||
|
||
// For other closures (graceful disposal, fatal errors), don't retry.
|
||
return client;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Stream reconnection
|
||
|
||
The Streamable HTTP client automatically reconnects its SSE event stream when the connection drops. This only applies to **stateful sessions** — the `GET` event stream is how the server sends unsolicited messages to the client, and it requires an active session. Stream reconnection is separate from session expiry: reconnection recovers the event stream within an existing session, while the previous example handles creating a new session after the server has terminated the old one.
|
||
|
||
If the server has an [event store](#session-resumability) configured, the client sends `Last-Event-ID` on reconnection so the server can replay missed events. See [Transports](xref:transports) for details on reconnection intervals and retry limits (<xref:ModelContextProtocol.Client.HttpClientTransportOptions.MaxReconnectionAttempts>, <xref:ModelContextProtocol.Client.HttpClientTransportOptions.DefaultReconnectionInterval>). If all reconnection attempts are exhausted, the transport closes and `McpClient.Completion` resolves.
|
||
|
||
#### Resuming a session
|
||
|
||
If the server is still tracking the session (or supports [session migration](#session-migration)), you can reconnect without re-initializing. Save the session metadata from the original client and pass it to <xref:ModelContextProtocol.Client.McpClient.ResumeSessionAsync*>:
|
||
|
||
- <xref:ModelContextProtocol.McpSession.SessionId> — set via <xref:ModelContextProtocol.Client.HttpClientTransportOptions.KnownSessionId>
|
||
- <xref:ModelContextProtocol.Client.McpClient.ServerCapabilities>
|
||
- <xref:ModelContextProtocol.Client.McpClient.ServerInfo>
|
||
- <xref:ModelContextProtocol.Client.McpClient.ServerInstructions> (optional)
|
||
- <xref:ModelContextProtocol.McpSession.NegotiatedProtocolVersion> (optional)
|
||
|
||
For a code example, see the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide.
|
||
|
||
Session resumption is useful when:
|
||
|
||
- The client process restarts but the server session is still alive.
|
||
- A transient network failure disconnects the client but the server hasn't timed out the session.
|
||
- You want to hand off a session between different parts of your application.
|
||
|
||
#### Terminating a session
|
||
|
||
When you dispose an `McpClient` (via `await using` or explicit `DisposeAsync`), the client sends an HTTP `DELETE` request to the session endpoint with the `Mcp-Session-Id` header. This tells the server to clean up the session immediately rather than waiting for the idle timeout.
|
||
|
||
The <xref:ModelContextProtocol.Client.HttpClientTransportOptions.OwnsSession> property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (for example, reading capabilities) without intending to own the session's lifetime.
|
||
|
||
### Client transport options
|
||
|
||
The following <xref:ModelContextProtocol.Client.HttpClientTransportOptions> properties affect client-side session behavior:
|
||
|
||
| Property | Default | Description |
|
||
|----------|---------|-------------|
|
||
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.KnownSessionId> | `null` | Pre-existing session ID for use with <xref:ModelContextProtocol.Client.McpClient.ResumeSessionAsync*>. When set, the client includes this session ID immediately and starts listening for unsolicited messages. |
|
||
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.EnableStandaloneGetStream> | `true` | Opens the standalone `GET` stream for unsolicited messages. Set to `false` to skip it; `POST` request/response streaming still works. |
|
||
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.OwnsSession> | `true` | Whether to send a `DELETE` request when the client is disposed. Set to `false` when you don't want disposal to terminate the server session. |
|
||
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.AdditionalHeaders> | `null` | Custom headers included in all requests (for example, for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. |
|
||
|
||
For transport-level options like reconnection intervals and transport mode, see [Transports](xref:transports).
|
||
|
||
## Server configuration
|
||
|
||
### Configuration reference
|
||
|
||
All session-related configuration is on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions>, configured via `WithHttpTransport`:
|
||
|
||
```csharp
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
// Recommended for servers that don't need sessions.
|
||
options.SessionMode = HttpServerSessionMode.Stateless;
|
||
|
||
// --- Options below only apply to stateful (non-stateless) mode ---
|
||
|
||
// How long a session can be idle before being closed (default: 2 hours)
|
||
options.IdleTimeout = TimeSpan.FromMinutes(30);
|
||
|
||
// Maximum number of idle sessions in memory (default: 10,000)
|
||
options.MaxIdleSessionCount = 1_000;
|
||
|
||
// Customize McpServerOptions per session with access to HttpContext
|
||
options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) =>
|
||
{
|
||
// Example: customize tools based on the authenticated user's roles
|
||
var user = httpContext.User;
|
||
if (user.IsInRole("admin"))
|
||
{
|
||
mcpServerOptions.ToolCollection = [.. adminTools];
|
||
}
|
||
};
|
||
});
|
||
```
|
||
|
||
### Property reference
|
||
|
||
| Property | Type | Default | Description |
|
||
|----------|------|---------|-------------|
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMode> | <xref:ModelContextProtocol.AspNetCore.HttpServerSessionMode> | `Stateless` | Selects how the server tracks state between requests: `Stateless` (no sessions), `Stateful` (sessions for every client, `2026-07-28` refused), or `StatefulForInitializeClients` ([hybrid](#hybrid-mode-sessions-for-initialize-clients-only)). |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> | `bool` | `true` | Convenience proxy over `SessionMode`: `true` maps to `Stateless`, `false` maps to `Stateful`, and hybrid mode reads as `false`. Use `SessionMode` to select hybrid mode. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> | `Func<HttpContext, McpServerOptions, CancellationToken, Task>?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.RunSessionHandler> | `Func<HttpContext, McpServer, CancellationToken, Task>?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMigrationHandler> | `ISessionMigrationHandler?` | `null` | _Stateful only (`MCP9006`)._ Enables cross-instance session migration. Can also be registered in DI. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EventStreamStore> | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. |
|
||
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.PerSessionExecutionContext> | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal<T>` values but prevents `IHttpContextAccessor` from working in handlers. |
|
||
|
||
The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `SessionMode = HttpServerSessionMode.Stateless`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path.
|
||
|
||
### ConfigureSessionOptions
|
||
|
||
<xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> is called when the server creates a new MCP server context, before the server starts processing requests. It receives the `HttpContext` from the `initialize` request, allowing you to customize the server based on the request (authentication, headers, route parameters, etc.).
|
||
|
||
In **stateful mode**, this callback runs once per session — when the client's initial `initialize` request creates the session.
|
||
|
||
```csharp
|
||
options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) =>
|
||
{
|
||
// Filter available tools based on a route parameter
|
||
var category = httpContext.Request.RouteValues["category"]?.ToString() ?? "all";
|
||
mcpServerOptions.ToolCollection = GetToolsForCategory(category);
|
||
|
||
// Set server info based on the authenticated user
|
||
var userName = httpContext.User.Identity?.Name;
|
||
mcpServerOptions.ServerInfo = new() { Name = $"MCP Server ({userName})" };
|
||
};
|
||
```
|
||
|
||
For a complete example that filters tools based on route parameters, see the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample.
|
||
|
||
#### Per-request configuration in stateless mode
|
||
|
||
In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP request** because each request creates a fresh server context. This makes it useful for per-request customization based on headers, authentication, or other request-specific data — similar to middleware:
|
||
|
||
```csharp
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
options.SessionMode = HttpServerSessionMode.Stateless;
|
||
options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) =>
|
||
{
|
||
// This runs on every request in stateless mode, so you can use the
|
||
// current HttpContext to customize tools, prompts, or resources.
|
||
var apiVersion = httpContext.Request.Headers["X-Api-Version"].ToString();
|
||
mcpServerOptions.ToolCollection = GetToolsForVersion(apiVersion);
|
||
return Task.CompletedTask;
|
||
};
|
||
})
|
||
.WithTools<DefaultTools>();
|
||
```
|
||
|
||
### Security and user binding
|
||
|
||
#### User binding
|
||
|
||
When authentication is configured, the server automatically binds sessions to the authenticated user. This prevents one user from hijacking another user's session.
|
||
|
||
##### How it works
|
||
|
||
1. When a session is created, the server captures the authenticated user's identity from `HttpContext.User`.
|
||
2. The server extracts a user ID claim in priority order:
|
||
- `ClaimTypes.NameIdentifier` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`)
|
||
- `"sub"` (OpenID Connect subject claim)
|
||
- `ClaimTypes.Upn` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn`)
|
||
3. On each subsequent request, the server validates that the current user matches the session's original user.
|
||
4. If there's a mismatch, the server responds with `403 Forbidden`.
|
||
|
||
This binding is automatic — no configuration is needed. If no authentication middleware is configured, user binding is skipped (the session is not bound to any user).
|
||
|
||
## Service lifetimes and DI scopes
|
||
|
||
How the server resolves scoped services depends on the transport and session mode. The <xref:ModelContextProtocol.Server.McpServerOptions.ScopeRequests> property controls whether the server creates a new `IServiceProvider` scope for each handler invocation.
|
||
|
||
### Stateful HTTP
|
||
|
||
In stateful mode, the server's <xref:ModelContextProtocol.Server.McpServer.Services> is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, <xref:ModelContextProtocol.Server.McpServerOptions.ScopeRequests> defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope.
|
||
|
||
This means:
|
||
|
||
- **Scoped services** are created fresh for each handler invocation and disposed when the handler completes.
|
||
- **Singleton services** resolve from the application container as usual.
|
||
- **Transient services** create a new instance per resolution, as usual.
|
||
|
||
### Stateless HTTP
|
||
|
||
In stateless mode, the server uses ASP.NET Core's per-request `HttpContext.RequestServices` as its service provider, and <xref:ModelContextProtocol.Server.McpServerOptions.ScopeRequests> is automatically set to `false`. No additional scopes are created — handlers share the same HTTP request scope that middleware and other ASP.NET Core components use.
|
||
|
||
This means:
|
||
|
||
- **Scoped services** behave exactly like any other ASP.NET Core request-scoped service — middleware can set state on a scoped service and the tool handler will see it.
|
||
- The DI lifetime model is identical to a standard ASP.NET Core controller or minimal API endpoint.
|
||
|
||
### stdio
|
||
|
||
The stdio transport creates a single server for the lifetime of the process. The server's <xref:ModelContextProtocol.Server.McpServer.Services> is the application-level `IServiceProvider`. By default, <xref:ModelContextProtocol.Server.McpServerOptions.ScopeRequests> is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP.
|
||
|
||
### McpServer.Create (custom transports)
|
||
|
||
When you create a server directly with <xref:ModelContextProtocol.Server.McpServer.Create*>, you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set <xref:ModelContextProtocol.Server.McpServerOptions.ScopeRequests> to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/blob/51a4fde4d9cfa12ef9430deef7daeaac36625be8/samples/InMemoryTransport/Program.cs#L6-L14) shows a minimal example of using `McpServer.Create` with in-memory pipes:
|
||
|
||
```csharp
|
||
Pipe clientToServerPipe = new(), serverToClientPipe = new();
|
||
|
||
await using var scope = serviceProvider.CreateAsyncScope();
|
||
|
||
await using McpServer server = McpServer.Create(
|
||
new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
|
||
new McpServerOptions
|
||
{
|
||
ScopeRequests = false, // The scope is already managed externally.
|
||
ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })]
|
||
},
|
||
serviceProvider: scope.ServiceProvider);
|
||
```
|
||
|
||
### DI scope summary
|
||
|
||
| Mode | Service provider | ScopeRequests | Handler scope |
|
||
|--------------------|-------------------------------|------------------|----------------------------------|
|
||
| **Stateful HTTP** | Application services | `true` (default) | New scope per handler invocation |
|
||
| **Stateless HTTP** | `HttpContext.RequestServices` | `false` (forced) | Shared HTTP request scope |
|
||
| **stdio** | Application services | `true` (default, configurable) | New scope per handler invocation |
|
||
| **McpServer.Create** | Caller-provided | Caller-controlled | Depends on `ScopeRequests` and whether the provider is already scoped |
|
||
|
||
## Cancellation and disposal
|
||
|
||
Every tool, prompt, and resource handler can receive a `CancellationToken`. The source and behavior of that token depends on the transport and session mode. The SDK also supports the MCP [cancellation protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) for client-initiated cancellation of individual requests.
|
||
|
||
### Handler cancellation tokens
|
||
|
||
| Mode | Token source | Cancelled when |
|
||
|------|-------------|----------------|
|
||
| **Stateless HTTP** | `HttpContext.RequestAborted` | Client disconnects, or ASP.NET Core shuts down. Identical to a standard minimal API or controller action. |
|
||
| **Stateful Streamable HTTP** | Linked token: HTTP request + application shutdown + session disposal | Client disconnects, `ApplicationStopping` fires, or the session is terminated (idle timeout, `DELETE`, max idle count). |
|
||
| **SSE (legacy)** | Linked token: `GET` request + application shutdown | Client disconnects the SSE stream, or `ApplicationStopping` fires. The entire session terminates with the `GET` stream. |
|
||
| **stdio** | Token passed to `McpServer.RunAsync()` | stdin EOF (client process exits), or the token is cancelled (for example, host shutdown via <kbd>Ctrl+C</kbd>). |
|
||
|
||
Stateless mode has the simplest cancellation story: the handler's `CancellationToken` is `HttpContext.RequestAborted` — the same token any ASP.NET Core endpoint receives. No additional tokens, linked sources, or session-level lifecycle to reason about.
|
||
|
||
### Client-initiated cancellation
|
||
|
||
In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific in-flight request by sending a [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) notification with the request ID. The SDK looks up the running handler and cancels its `CancellationToken`. This can result in an `OperationCanceledException` if the handler is awaiting a cancellation-aware operation when the token is cancelled.
|
||
|
||
- Invalid or unknown request IDs are silently ignored.
|
||
- In stateless mode, there is no persistent session to receive the notification on, so client-initiated cancellation does not apply.
|
||
- For [task-augmented requests](xref:tasks), the MCP specification requires using [`tasks/cancel`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#cancelling-tasks) instead of `notifications/cancelled`. The SDK uses a separate cancellation token per task (independent of the original HTTP request), so `tasks/cancel` can cancel a task even after the initial request has completed. See [Tasks and session modes](#tasks-and-session-modes) for details.
|
||
|
||
### Server and session disposal
|
||
|
||
When an `McpServer` is disposed — whether due to session termination, transport closure, or application shutdown — the SDK **awaits all in-flight handlers** before `DisposeAsync()` returns. This means:
|
||
|
||
- Handlers have an opportunity to complete cleanup (for example, flushing writes, releasing locks).
|
||
- Scoped services created for the handler are disposed after the handler completes.
|
||
- The SDK logs each handler's completion at `Information` level, including elapsed time.
|
||
|
||
#### Graceful shutdown in ASP.NET Core
|
||
|
||
When `ApplicationStopping` fires (for example, `SIGTERM`, <kbd>Ctrl+C</kbd>, `app.StopAsync()`), the SDK immediately cancels active SSE and `GET` streams so that connected clients don't block shutdown. In-flight `POST` request handlers continue running and are awaited before the server finishes disposing. The total shutdown time is bounded by ASP.NET Core's `HostOptions.ShutdownTimeout` (default: **30 seconds**). In practice, the SDK completes shutdown well within this limit.
|
||
|
||
For stateless servers, shutdown is even simpler: each request is independent, so there are no long-lived sessions to drain — just standard ASP.NET Core request completion.
|
||
|
||
#### stdio process lifecycle
|
||
|
||
- **Graceful shutdown** (stdin EOF, `SIGTERM`, <kbd>Ctrl+C</kbd>): The transport closes, in-flight handlers are awaited, and `McpServer.DisposeAsync()` runs normally.
|
||
- **Process kill** (`SIGKILL`): No cleanup occurs. Handlers are interrupted mid-execution, and no disposal code runs. This is inherent to process-level termination and not specific to the SDK.
|
||
|
||
### Stateless per-request logging
|
||
|
||
In stateless mode, each HTTP request creates and disposes a short-lived `McpServer` instance. This produces session lifecycle log entries at `Trace` level (`session created` / `session disposed`) for every request. These entries are typically invisible at default log levels but might appear when troubleshooting with verbose logging enabled. There is no user-facing `initialize` handshake in stateless mode — the SDK handles the per-request server lifecycle internally.
|
||
|
||
## Tasks and session modes
|
||
|
||
[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore> configured (enabled via `WithTasks`), and behavior differs between session modes.
|
||
|
||
### Stateless mode
|
||
|
||
Tasks are a natural fit for stateless servers. The client sends a task-augmented `tools/call` request, receives a task ID immediately, and polls for completion with `tasks/get` on subsequent independent HTTP requests. Because each request creates an ephemeral `McpServer` that shares the same `IMcpTaskStore`, all task operations work without any persistent session.
|
||
|
||
In stateless mode, there is no `SessionId`, so the task store does not apply session-based isolation. All tasks are accessible from any request to the same server. This is typically fine for single-purpose servers or when authentication middleware already identifies the caller.
|
||
|
||
### Stateful mode
|
||
|
||
In stateful mode, the `IMcpTaskStore` tracks tasks created for that server instance. The built-in <xref:ModelContextProtocol.Extensions.Tasks.InMemoryMcpTaskStore> keeps tasks in memory; implement a custom store when your scenario requires durable storage or isolation across server instances.
|
||
|
||
Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore> backed by an external store. For guidance, see [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store).
|
||
|
||
### Task cancellation vs. request cancellation
|
||
|
||
The MCP specification defines two distinct cancellation mechanisms:
|
||
|
||
- **`notifications/cancelled`** cancels a regular in-flight request by its JSON-RPC request ID. The SDK looks up the handler's `CancellationToken` and cancels it. This is a fire-and-forget notification with no response.
|
||
- **`tasks/cancel`** cancels a task by its task ID. The SDK signals a separate per-task `CancellationToken` (independent of the original request) and updates the task's status to `cancelled` in the store. This is a request-response operation that returns the final task state.
|
||
|
||
For task-augmented requests, the specification [requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) using `tasks/cancel` instead of `notifications/cancelled`.
|
||
|
||
## Request backpressure
|
||
|
||
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.
|
||
|
||
### Default stateful mode (no EventStreamStore, no tasks)
|
||
|
||
In the default configuration, each JSON-RPC request holds its `POST` response open until the handler produces a result. The `POST` response body is an SSE stream that carries the JSON-RPC response, and the server awaits the handler's completion before closing it. This means:
|
||
|
||
- Each in-flight handler occupies one HTTP/2 stream
|
||
- The HTTP server's `MaxStreamsPerConnection` (default: **100** in Kestrel) limits concurrent handlers per connection
|
||
- This is the same backpressure model as **gRPC unary calls** — one request occupies one stream until the response is sent
|
||
|
||
One difference from gRPC: handler cancellation tokens are linked to the **session** lifetime, not `HttpContext.RequestAborted`. If a client disconnects from a `POST` mid-flight, the handler continues running until it completes or the session is terminated. But the client has freed a stream slot, so it can submit a new request — meaning the server could accumulate up to `MaxStreamsPerConnection` handlers that outlive their original connections. In practice this is bounded and comparable to how gRPC handlers behave when the client cancels an RPC.
|
||
|
||
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.
|
||
|
||
### SSE (legacy — opt-in only)
|
||
|
||
Legacy SSE endpoints are [disabled by default](#legacy-sse-transport) 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.
|
||
|
||
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.
|
||
|
||
Internally, handlers are dispatched with the same fire-and-forget pattern as Streamable HTTP (`_ = ProcessMessageAsync()`). A client can send unlimited `POST` requests to `/message` while keeping the `GET` stream open, and each one spawns a concurrent handler with no built-in limit.
|
||
|
||
The `GET` stream does provide **session lifetime bounds**: handler cancellation tokens are linked to the `GET` request's `HttpContext.RequestAborted`, so when the client disconnects the SSE stream, all in-flight handlers are cancelled. This is similar to SignalR's connection-bound lifetime model — but unlike SignalR, there is no per-client concurrency limit like `MaximumParallelInvocationsPerClient`. The `GET` stream provides cleanup on disconnect, not rate-limiting during the connection.
|
||
|
||
### With EventStreamStore
|
||
|
||
<xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EventStreamStore> is an advanced API that enables session resumability — storing SSE events so clients can reconnect and replay missed messages using the `Last-Event-ID` header. When configured, handlers gain the ability to call `EnablePollingAsync()`, which closes the `POST` response early and switches the client to polling mode.
|
||
|
||
When a handler calls `EnablePollingAsync()`:
|
||
|
||
- The `POST` response completes **before the handler finishes**
|
||
- The handler continues running in the background, decoupled from any HTTP request
|
||
- The client's HTTP/2 stream slot is freed, allowing it to submit more requests
|
||
- **HTTP-level backpressure no longer applies** — there is no built-in limit on how many concurrent handlers can accumulate
|
||
|
||
The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expiration, 30-minute sliding window) that govern event retention, but these do not limit handler concurrency. If you enable `EventStreamStore` on a public-facing server, apply **HTTP rate-limiting middleware** and **reverse proxy limits** to compensate for the loss of stream-level backpressure.
|
||
|
||
### With tasks
|
||
|
||
[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore>, starts the tool handler as a fire-and-forget background task, and returns the task ID immediately, so the `POST` response completes **before the handler starts its real work**.
|
||
|
||
This means:
|
||
|
||
- **No HTTP-level backpressure on task handlers** — each `POST` returns almost immediately, freeing the stream slot
|
||
- A client can rapidly submit many task-augmented requests, each spawning a background handler with no concurrency limit
|
||
- Task cleanup is governed by TTL (time-to-live), not by handler completion or session termination
|
||
|
||
Tasks are a natural fit for **stateless deployments at scale**, where the `IMcpTaskStore` is backed by an external store (database, distributed cache) and the client polls `tasks/get` independently. In this model, work distribution and concurrency control are handled by your infrastructure (job queues, worker pools) rather than by HTTP stream limits.
|
||
|
||
For servers using the built-in automatic task handlers without external work distribution, apply the same rate-limiting and reverse-proxy protections recommended for `EventStreamStore` deployments.
|
||
|
||
### Stateless mode
|
||
|
||
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 the [Handler cancellation tokens](#handler-cancellation-tokens) section).
|
||
|
||
### Summary
|
||
|
||
| Configuration | `POST` held open? | Backpressure mechanism | Concurrent handler limit per connection |
|
||
|---|---|---|---|
|
||
| **Stateless** | Yes (handler = request) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) |
|
||
| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) |
|
||
| **SSE (legacy — opt-in)** | No (returns 202 Accepted) | None built-in; `GET` stream provides cleanup | Unbounded — apply rate limiting |
|
||
| **Stateful + EventStreamStore** | No (if `EnablePollingAsync()` called) | None built-in | Unbounded — apply rate limiting |
|
||
| **Stateful + Tasks** | No (returns task ID immediately) | None built-in | Unbounded — apply rate limiting |
|
||
|
||
## Observability
|
||
|
||
The SDK's tracing and metrics work in **all modes** — stateful, stateless, and stdio — and do not depend on sessions. Distributed tracing is purely request-scoped: [W3C trace context](https://www.w3.org/TR/trace-context/) (`traceparent` / `tracestate`) propagates through the `_meta` field in JSON-RPC messages, so a client's tool call and the server's handling appear as parent-child spans regardless of transport or session mode.
|
||
|
||
### The `mcp.session.id` activity tag
|
||
|
||
Every request `Activity` is tagged with `mcp.session.id` — a unique identifier generated independently by each <xref:ModelContextProtocol.Client.McpClient> and <xref:ModelContextProtocol.Server.McpServer> instance. **Despite the name, this is not the transport session ID** (`Mcp-Session-Id` header). It is a per-instance GUID that tracks the lifetime of that specific client or server object.
|
||
|
||
- **Stateful mode**: The server's `mcp.session.id` is stable for the lifetime of the session. This makes it useful for correlating all operations handled by a single long-lived `McpServer` instance — you can filter your observability platform to see every tool call, notification, and request within one session.
|
||
- **Stateless mode**: Each HTTP request creates a new `McpServer` instance with its own `mcp.session.id`, so the tag effectively identifies individual requests. This is simpler — the HTTP request's own `Activity` is the natural parent, and there's no long-lived session to correlate.
|
||
- The client and server always have **different** `mcp.session.id` values, even when they share the same transport session ID.
|
||
|
||
### Correlating with the transport session ID
|
||
|
||
The transport session ID (<xref:ModelContextProtocol.McpSession.SessionId>, the `Mcp-Session-Id` header value) and the `mcp.session.id` activity tag are not automatically correlated by the SDK. You can bridge this gap by tagging the ASP.NET Core request `Activity` with the transport session ID using an [endpoint filter](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/min-api-filters) on `MapMcp()`:
|
||
|
||
```csharp
|
||
app.MapMcp().AddEndpointFilter(async (context, next) =>
|
||
{
|
||
var httpContext = context.HttpContext;
|
||
|
||
// The session ID is available in the request header on all non-initialize requests
|
||
// in stateful mode (the client echoes back the ID it received from the server's
|
||
// initialize response). It is null for the first initialize request and always null
|
||
// in stateless mode. Tag before next() so child spans inherit the value.
|
||
string? sessionId = httpContext.Request.Headers["Mcp-Session-Id"];
|
||
if (sessionId != null)
|
||
{
|
||
Activity.Current?.AddTag("mcp.transport.session.id", sessionId);
|
||
}
|
||
|
||
return await next(context);
|
||
});
|
||
```
|
||
|
||
<!-- mlc-disable-next-line -->
|
||
> [!NOTE]
|
||
> The tag is added **before** calling `next()` so that any child activities created during request processing inherit it. The trade-off is that the very first `initialize` request won't have the tag, because the client doesn't have a session ID yet — the server assigns it in the response. All subsequent requests will have it.
|
||
|
||
<!-- mlc-disable-next-line -->
|
||
> [!NOTE]
|
||
> The `AllowNewSessionForNonInitializeRequests` AppContext switch (`ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests`) is a back-compat escape hatch that allows creating new sessions from non-initialize `POST` requests that arrive without an `Mcp-Session-Id` header. When enabled, the server creates a **brand-new session** for each such request rather than rejecting it — the response still carries the `Mcp-Session-Id` header with the new session's ID. This is **non-compliant with the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)**, which requires that only `initialize` requests create sessions. Use it only as a temporary workaround for clients that don't implement the session protocol correctly.
|
||
|
||
### Other activity tags
|
||
|
||
Other tags include `mcp.method.name`, `mcp.protocol.version`, `jsonrpc.request.id`, and operation-specific tags like `gen_ai.tool.name` for tool calls. Use these to filter and group traces in your observability platform (Jaeger, Zipkin, Application Insights, etc.).
|
||
|
||
### Metrics
|
||
|
||
The SDK records histograms under the `Experimental.ModelContextProtocol` meter:
|
||
|
||
| Metric | Description |
|
||
|---------------------------------|-----------------------------------------------------|
|
||
| `mcp.server.session.duration` | Duration of the MCP session on the server |
|
||
| `mcp.client.session.duration` | Duration of the MCP session on the client |
|
||
| `mcp.server.operation.duration` | Duration of each request/notification on the server |
|
||
| `mcp.client.operation.duration` | Duration of each request/notification on the client |
|
||
|
||
In stateless mode, each HTTP request is its own "session", so `mcp.server.session.duration` measures individual request lifetimes rather than long-lived session durations.
|
||
|
||
## Legacy SSE transport
|
||
|
||
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** due to [backpressure concerns](#request-backpressure). To enable them, set <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9004`) 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`.
|
||
|
||
> [!NOTE]
|
||
> Setting `EnableLegacySse = true` while `SessionMode = HttpServerSessionMode.Stateless` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests.
|
||
|
||
### How SSE sessions work
|
||
|
||
1. The client connects to the `/sse` endpoint with a `GET` request.
|
||
2. The server generates a session ID and sends a `/message?sessionId={id}` URL as the first SSE event.
|
||
3. The client sends JSON-RPC messages as `POST` requests to that `/message?sessionId={id}` URL.
|
||
4. The server streams responses and unsolicited messages back over the open SSE `GET` stream.
|
||
|
||
Unlike Streamable HTTP, which uses the `Mcp-Session-Id` header, legacy SSE passes the session ID as a query string parameter on the `/message` endpoint.
|
||
|
||
### Session lifetime
|
||
|
||
SSE session lifetime is tied directly to the `GET` SSE stream. When the client disconnects (detected via `HttpContext.RequestAborted`), or the server shuts down (via `IHostApplicationLifetime.ApplicationStopping`), the session is immediately removed. There is no idle timeout or maximum idle session count for SSE sessions — the session exists exactly as long as the SSE connection is open.
|
||
|
||
This makes SSE sessions behave similarly to [stdio](#stdio-transport): the session is implicit in the connection lifetime, and disconnection is the only termination mechanism.
|
||
|
||
### Configuration
|
||
|
||
<xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> and <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.RunSessionHandler> both work with SSE sessions. They are called during the `/sse` `GET` request handler, and services resolve from the `GET` request's `HttpContext.RequestServices`. [User binding](#user-binding) also works — the authenticated user is captured from the `GET` request and verified on each `POST` to `/message`.
|
||
|
||
## Advanced features
|
||
|
||
### Session migration
|
||
|
||
For high-availability deployments, <xref:ModelContextProtocol.AspNetCore.ISessionMigrationHandler> enables session migration across server instances. When a request arrives with a session ID that isn't found locally, the handler is consulted to attempt migration.
|
||
|
||
```csharp
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
// Session migration is a stateful-mode feature.
|
||
options.SessionMode = HttpServerSessionMode.Stateful;
|
||
options.SessionMigrationHandler = new MySessionMigrationHandler();
|
||
});
|
||
```
|
||
|
||
You can also register the handler in DI:
|
||
|
||
```csharp
|
||
builder.Services.AddSingleton<ISessionMigrationHandler, MySessionMigrationHandler>();
|
||
```
|
||
|
||
Implementations should:
|
||
|
||
- Validate that the request is authorized (check `HttpContext.User`).
|
||
- Reconstruct the session state from external storage (database, distributed cache, etc.).
|
||
- Return `McpServerOptions` pre-populated with `KnownClientInfo` and `KnownClientCapabilities` to skip re-initialization.
|
||
|
||
Session migration adds significant complexity. Consider whether stateless mode is a better fit for your deployment scenario.
|
||
|
||
### Session resumability
|
||
|
||
The server can store SSE events for replay when clients reconnect using the `Last-Event-ID` header. Configure this with <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EventStreamStore>:
|
||
|
||
```csharp
|
||
builder.Services.AddMcpServer()
|
||
.WithHttpTransport(options =>
|
||
{
|
||
// Session resumability is a stateful-mode feature.
|
||
options.SessionMode = HttpServerSessionMode.Stateful;
|
||
options.EventStreamStore = new MyEventStreamStore();
|
||
});
|
||
```
|
||
|
||
When configured:
|
||
|
||
- The server generates unique event IDs for each SSE message
|
||
- Events are stored for later replay
|
||
- When a client reconnects with `Last-Event-ID`, missed events are replayed before new events are sent
|
||
|
||
This is useful for clients that might experience transient network issues. Without an event store, clients that disconnect and reconnect might miss events that were sent while they were disconnected.
|