From c04f19808ba1d665e1612df7da0d26fadb4bab01 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 25 Mar 2026 18:03:06 -0700 Subject: [PATCH 01/42] Add sessions doc and prefer stateless mode in docs, samples, and error 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> --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/filters.md | 5 +- docs/concepts/getting-started.md | 8 +- docs/concepts/httpcontext/samples/Program.cs | 5 +- docs/concepts/index.md | 1 + docs/concepts/logging/logging.md | 2 +- .../progress/samples/server/Program.cs | 5 +- docs/concepts/sessions/sessions.md | 339 ++++++++++++++++++ docs/concepts/toc.yml | 4 + docs/concepts/transports/transports.md | 11 +- samples/AspNetCoreMcpServer/Program.cs | 4 + samples/EverythingServer/Program.cs | 7 + samples/ProtectedMcpServer/Program.cs | 8 +- .../HttpServerTransportOptions.cs | 5 + .../StreamableHttpHandler.cs | 10 +- .../StatelessServerTests.cs | 87 +++++ .../StreamableHttpServerConformanceTests.cs | 23 ++ 17 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 docs/concepts/sessions/sessions.md diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 3f7759843..55ba20540 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici ### URL Elicitation Required Error -When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in is enabled disabling server-to-client requests), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. +When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](sessions/sessions.md) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. #### Throwing UrlElicitationRequiredException on the Server diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 9f63dd962..21b85392b 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -544,7 +544,10 @@ builder.Services.AddAuthentication("Bearer") builder.Services.AddAuthorization(); builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + options.Stateless = true; + }) .AddAuthorizationFilters() // Required for authorization support .WithTools() .WithRequestFilters(requestFilters => diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 6e096d767..f009ec31e 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -79,7 +79,13 @@ using System.ComponentModel; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + // Stateless mode is recommended for servers that don't need + // server-to-client requests like sampling or elicitation. + // See the Sessions documentation for details. + options.Stateless = true; + }) .WithToolsFromAssembly(); var app = builder.Build(); diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs index 043e6069d..a01602d40 100644 --- a/docs/concepts/httpcontext/samples/Program.cs +++ b/docs/concepts/httpcontext/samples/Program.cs @@ -5,7 +5,10 @@ // Add services to the container. builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + options.Stateless = true; + }) .WithTools(); // diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 85d94492f..06ccec5fb 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -36,5 +36,6 @@ Install the SDK and build your first MCP client and server. | [Prompts](prompts/prompts.md) | Learn how to implement and consume reusable prompt templates with rich content types. | | [Completions](completions/completions.md) | Learn how to implement argument auto-completion for prompts and resource templates. | | [Logging](logging/logging.md) | Learn how to implement logging in MCP servers and how clients can consume log messages. | +| [Sessions](sessions/sessions.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. | | [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. | diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index f9a54d4aa..a01d18c3a 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -46,7 +46,7 @@ MCP servers that implement the Logging utility must declare this in the capabili [Initialization]: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server -to send log messages—only allows it. Note that stateless MCP servers might not be capable of sending log +to send log messages—only allows it. Note that [stateless](sessions/sessions.md) MCP servers might not be capable of sending log messages as there might not be an open connection to the client on which the log messages could be sent. The C# SDK provides an extension method on to allow the diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs index 7216b2fe1..cfff45808 100644 --- a/docs/concepts/progress/samples/server/Program.cs +++ b/docs/concepts/progress/samples/server/Program.cs @@ -5,7 +5,10 @@ // Add services to the container. builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + options.Stateless = true; + }) .WithTools(); builder.Logging.AddConsole(options => diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md new file mode 100644 index 000000000..1a480dcb8 --- /dev/null +++ b/docs/concepts/sessions/sessions.md @@ -0,0 +1,339 @@ +--- +title: Sessions +author: halter73 +description: How sessions work in the MCP C# SDK and when to use stateless vs. stateful mode for HTTP servers. +uid: sessions +--- + +# Sessions + +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. Sessions enable features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, resource subscriptions, and session-scoped state. However, **most servers don't need sessions and should run in stateless mode** to avoid unnecessary complexity, memory overhead, and deployment constraints. + +[Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + +## 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 +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools(); + +var app = builder.Build(); +app.MapMcp(); +app.Run(); +``` + +### What stateless mode disables + +When is `true`: + +- 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 +- 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 the legacy `/sse` endpoint is disabled +- **Server-to-client requests are disabled**, including: + - [Sampling](xref:sampling) (`SampleAsync`) + - [Elicitation](xref:elicitation) (`ElicitAsync`) + - [Roots](xref:roots) (`RequestRootsAsync`) +- Unsolicited server-to-client notifications (e.g., resource update notifications, logging messages) are not supported + +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. + + +> [!TIP] +> If you're unsure whether you need sessions, start with stateless mode. You can always switch to stateful mode later if you need server-to-client requests or other session features. + +### Stateless alternatives for server-to-client interactions + + +> [!NOTE] +> Multi Round-Trip Requests (MRTR) is a proposed experimental feature that is not yet available. See PR [#1458](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for the reference implementation and specification proposal. + +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)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a proposed alternative that works with stateless servers by inverting the communication model — 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, LLM reasoning, or other client input can still run in stateless mode when both sides support MRTR. + +## Stateful mode (sessions) + +When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: + +- Server-to-client requests (sampling, elicitation, roots) via an open SSE stream +- Unsolicited notifications (resource updates, logging messages) +- Resource subscriptions +- Session-scoped state (e.g., per-session DI scopes, RunSessionHandler) + +### 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**: Sending resource-changed notifications or log messages without a preceding client request +- **Resource subscriptions**: Clients subscribing to resource changes and receiving updates +- **Session-scoped state**: Logic that must persist across multiple requests within the same session +- **Debugging stdio servers over HTTP**: When you want to test a typically stateful stdio server over HTTP while supporting concurrent connections from editors like Claude Code, GitHub Copilot in VS Code, Cursor, etc., sessions let you distinguish between them + +### Deployment footguns + +Stateful sessions introduce several challenges that you should carefully consider: + +#### 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 and 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 may not handle gracefully. + +You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. + +#### Clients that don't send Mcp-Session-Id + +Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. + +## 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. + +## Session lifecycle (HTTP) + +### Creation + +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 (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 session. Activity is recorded when: + +- A request arrives for the session (POST, GET, or DELETE) +- A response is sent for the session + +### Idle timeout + +Sessions that have no activity for the duration of (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 an open `GET` request (SSE stream). Sessions with an active `GET` request are never considered idle. + +When a session times out: + +- The session's `McpServer` is disposed +- Any pending requests receive cancellation +- A client trying 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 + + (default: **10,000**) limits how many idle 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 an active `GET` request (open SSE stream) don't count toward this limit. + +### Termination + +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 + +## Configuration reference + +All session-related configuration is on , configured via `WithHttpTransport`: + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + // Recommended for servers that don't need sessions. + options.Stateless = true; + + // --- 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 | +|----------|------|---------|-------------| +| | `bool` | `false` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests. | +| | `TimeSpan` | 2 hours | Duration of inactivity before a session is closed. Checked every 5 seconds. | +| | `int` | 10,000 | Maximum idle sessions before the oldest are forcibly terminated. | +| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode, this runs on every HTTP request. | +| | `Func?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. | +| | `ISessionMigrationHandler?` | `null` | Enables cross-instance session migration. Can also be registered in DI. | +| | `ISseEventStreamStore?` | `null` | Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | +| | `bool` | `false` | Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | + +## Per-session configuration + + 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})" }; +}; +``` + +See the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample for a complete example that filters tools based on route parameters. + +### 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.Stateless = true; + 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(); +``` + +## 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). + +## Session migration + +For high-availability deployments, 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 => + { + options.SessionMigrationHandler = new MySessionMigrationHandler(); + }); +``` + +You can also register the handler in DI: + +```csharp +builder.Services.AddSingleton(); +``` + +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 : + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + 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 may experience transient network issues. Without an event store, clients that disconnect and reconnect may miss events that were sent while they were disconnected. + +## Choosing stateless vs. stateful + +| 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** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | +| **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | +| **Resource subscriptions** | Not supported | Supported | +| **Client compatibility** | Works with all clients | Requires clients to track and send `Mcp-Session-Id` | diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index d04eeb707..0846dc68a 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -21,6 +21,8 @@ items: uid: tasks - name: Client Features items: + - name: Sampling + uid: sampling - name: Roots uid: roots - name: Elicitation @@ -37,6 +39,8 @@ items: uid: completions - name: Logging uid: logging + - name: Sessions + uid: sessions - name: HTTP Context uid: httpcontext - name: Filters diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 55623d51a..bdfd35bd9 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -119,7 +119,11 @@ Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTT var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + // Recommended for servers that don't need server-to-client requests. + options.Stateless = true; + }) .WithTools(); var app = builder.Build(); @@ -127,6 +131,8 @@ app.MapMcp(); app.Run(); ``` +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](sessions/sessions.md) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. + A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: [AspNetCoreMcpPerSessionTools]: https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools @@ -197,6 +203,7 @@ No additional configuration is needed. When a client connects using the SSE prot |---------|-------|----------------|--------------| | Process model | Child process | Remote HTTP | Remote HTTP | | Direction | Bidirectional | Bidirectional | Server→client stream + client→server POST | -| Session resumption | N/A | ✓ | ✗ | +| Session resumption | N/A | ✓ (stateful mode) | ✗ | +| Stateless mode | N/A | ✓ ([recommended](sessions/sessions.md)) | ✗ | | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools | Remote servers | Legacy compatibility | diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 96f89bffa..41df0fc0b 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -6,6 +6,10 @@ using System.Net.Http.Headers; var builder = WebApplication.CreateBuilder(args); +// Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send +// a server-to-client sampling request. This requires stateful (session-based) mode, which +// is the default. See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for details +// on when to prefer stateless mode instead. builder.Services.AddMcpServer() .WithHttpTransport() .WithTools() diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index 99d7759bd..5a0c36aec 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -15,6 +15,13 @@ var builder = WebApplication.CreateBuilder(args); +// Note: This sample requires stateful (session-based) mode because it uses: +// - SampleLlmTool: server-to-client sampling via SampleAsync +// - Resource subscriptions: unsolicited notifications via SendNotificationAsync +// - Per-session state: subscription tracking keyed by SessionId +// See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for details +// on when to prefer stateless mode instead. + // Dictionary of session IDs to a set of resource URIs they are subscribed to // The value is a ConcurrentDictionary used as a thread-safe HashSet // because .NET does not have a built-in concurrent HashSet diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index 97f8456a2..4980e23dd 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -67,7 +67,13 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddMcpServer() .WithTools() - .WithHttpTransport(); + .WithHttpTransport(options => + { + // Stateless mode is recommended for servers that don't need server-to-client + // requests like sampling or elicitation. It enables horizontal scaling without + // session affinity and works with clients that don't send Mcp-Session-Id. + options.Stateless = true; + }); // Configure HttpClientFactory for weather.gov API builder.Services.AddHttpClient("WeatherApi", client => diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 0338911d3..80b89f3a4 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -17,6 +17,11 @@ public class HttpServerTransportOptions /// Gets or sets an optional asynchronous callback to configure per-session /// with access to the of the request that initiated the session. /// + /// + /// In stateful mode (the default), this callback is invoked once per session when the client sends the + /// initialize request. In mode, it is invoked on every HTTP request + /// because each request creates a fresh server context. + /// public Func? ConfigureSessionOptions { get; set; } /// diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 290eca4cc..1655230f9 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -219,7 +219,11 @@ public async Task HandleDeleteRequestAsync(HttpContext context) { if (string.IsNullOrEmpty(sessionId)) { - await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required", StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, + "Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " + + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + + "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for more details.", + StatusCodes.Status400BadRequest); return null; } @@ -302,7 +306,9 @@ await WriteJsonRpcErrorAsync(context, && message is not JsonRpcRequest { Method: RequestMethods.Initialize }) { await WriteJsonRpcErrorAsync(context, - "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests.", + "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + + "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + + "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for more details.", StatusCodes.Status400BadRequest); return null; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 0f7c7e7e0..1a8f42e8b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using System.Collections.Concurrent; using System.Diagnostics; using System.Net; @@ -189,6 +190,92 @@ public async Task ScopedServices_Resolve_FromRequestScope() Assert.Equal("From request middleware!", Assert.IsType(toolContent).Text); } + [Fact] + public async Task ProgressNotifications_Work_InStatelessMode() + { + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools([McpServerTool.Create( + [System.ComponentModel.Description("Reports progress")] (IProgress progress) => + { + for (int i = 0; i < 5; i++) + { + progress.Report(new() { Progress = i, Total = 5, Message = $"Step {i}" }); + } + return "complete"; + }, new() { Name = "progressTool" })]); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + await using var client = await ConnectMcpClientAsync(); + + var progressMessages = new ConcurrentBag(); + var toolResponse = await client.CallToolAsync( + "progressTool", + progress: new Progress(p => progressMessages.Add(p.Message!)), + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(toolResponse.Content); + Assert.Equal("complete", Assert.IsType(content).Text); + // Progress posts callbacks to the thread pool asynchronously, so we need to wait + // briefly for them to fire after CallToolAsync returns the tool response. + var sw = Stopwatch.StartNew(); + while (progressMessages.IsEmpty && sw.Elapsed < TimeSpan.FromSeconds(5)) + { + await Task.Delay(50, TestContext.Current.CancellationToken); + } + + Assert.NotEmpty(progressMessages); + } + + [Fact] + public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode() + { + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => + { + // Dynamically add a tool based on a request header value. + var toolSuffix = httpContext.Request.Headers["X-Tool-Suffix"].ToString(); + if (!string.IsNullOrEmpty(toolSuffix)) + { + mcpServerOptions.ToolCollection = + [ + McpServerTool.Create(() => $"configured-{toolSuffix}", new() { Name = "dynamicTool" }) + ]; + } + + return Task.CompletedTask; + }; + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + // First request: set the header so ConfigureSessionOptions creates the tool. + HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "alpha"); + + await using var client = await ConnectMcpClientAsync(); + + var toolResponse = await client.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken); + var content = Assert.Single(toolResponse.Content); + Assert.Equal("configured-alpha", Assert.IsType(content).Text); + } + [McpServerTool(Name = "testSamplingErrors")] public static async Task TestSamplingErrors(McpServer server) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index ff566f533..bbe642ab6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -241,6 +241,29 @@ public async Task PostWithoutSessionId_NonInitializeRequest_Returns400() using var response = await HttpClient.PostAsync("", JsonContent(ListToolsRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Mcp-Session-Id", body); + Assert.Contains("Stateless", body); + } + + [Fact] + public async Task GetWithoutSessionId_Returns400_WithStatelessGuidance() + { + await StartAsync(); + await CallInitializeAndValidateAsync(); + + // Clear session ID and send GET without it. + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Accept.Clear(); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Mcp-Session-Id", body); + Assert.Contains("Stateless", body); } [Fact] From e63ec2134ace12bbc17502e8fbdace70924197c8 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 25 Mar 2026 18:16:44 -0700 Subject: [PATCH 02/42] Fix relative links and address PR review feedback - 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> --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/logging/logging.md | 2 +- docs/concepts/transports/transports.md | 4 ++-- .../StreamableHttpHandler.cs | 4 ++-- .../StatelessServerTests.cs | 20 ++++++++++++++----- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 55ba20540..c6e2168ee 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici ### URL Elicitation Required Error -When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](sessions/sessions.md) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. +When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](../sessions/sessions.md) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. #### Throwing UrlElicitationRequiredException on the Server diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index a01d18c3a..2c307ac42 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -46,7 +46,7 @@ MCP servers that implement the Logging utility must declare this in the capabili [Initialization]: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server -to send log messages—only allows it. Note that [stateless](sessions/sessions.md) MCP servers might not be capable of sending log +to send log messages—only allows it. Note that [stateless](../sessions/sessions.md) MCP servers might not be capable of sending log messages as there might not be an open connection to the client on which the log messages could be sent. The C# SDK provides an extension method on to allow the diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index bdfd35bd9..5c1413eaf 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -131,7 +131,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](sessions/sessions.md) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](../sessions/sessions.md) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: @@ -204,6 +204,6 @@ No additional configuration is needed. When a client connects using the SSE prot | Process model | Child process | Remote HTTP | Remote HTTP | | Direction | Bidirectional | Bidirectional | Server→client stream + client→server POST | | Session resumption | N/A | ✓ (stateful mode) | ✗ | -| Stateless mode | N/A | ✓ ([recommended](sessions/sessions.md)) | ✗ | +| Stateless mode | N/A | ✓ ([recommended](../sessions/sessions.md)) | ✗ | | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools | Remote servers | Legacy compatibility | diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 1655230f9..3445a8c87 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -222,7 +222,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + - "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for more details.", + "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for more details.", StatusCodes.Status400BadRequest); return null; } @@ -308,7 +308,7 @@ await WriteJsonRpcErrorAsync(context, await WriteJsonRpcErrorAsync(context, "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + - "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for more details.", + "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for more details.", StatusCodes.Status400BadRequest); return null; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 1a8f42e8b..b4a284b36 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -266,14 +266,24 @@ public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode() HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // First request: set the header so ConfigureSessionOptions creates the tool. + // First request with "alpha" — proves ConfigureSessionOptions runs and configures the tool. HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "alpha"); - await using var client = await ConnectMcpClientAsync(); + await using var client1 = await ConnectMcpClientAsync(); - var toolResponse = await client.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken); - var content = Assert.Single(toolResponse.Content); - Assert.Equal("configured-alpha", Assert.IsType(content).Text); + var toolResponse1 = await client1.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken); + var content1 = Assert.Single(toolResponse1.Content); + Assert.Equal("configured-alpha", Assert.IsType(content1).Text); + + // Second request with "beta" — proves ConfigureSessionOptions runs again with new request data. + HttpClient.DefaultRequestHeaders.Remove("X-Tool-Suffix"); + HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "beta"); + + await using var client2 = await ConnectMcpClientAsync(); + + var toolResponse2 = await client2.CallToolAsync("dynamicTool", cancellationToken: TestContext.Current.CancellationToken); + var content2 = Assert.Single(toolResponse2.Content); + Assert.Equal("configured-beta", Assert.IsType(content2).Text); } [McpServerTool(Name = "testSamplingErrors")] From 1a8bf45e85f4063ce22325570553e148bd43cbea Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 25 Mar 2026 18:32:08 -0700 Subject: [PATCH 03/42] Remove flaky stateless progress test 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 helper avoids the thread pool posting race inherent to Progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StatelessServerTests.cs | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index b4a284b36..3cc8e53ed 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -4,7 +4,6 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; @@ -193,18 +192,22 @@ public async Task ScopedServices_Resolve_FromRequestScope() [Fact] public async Task ProgressNotifications_Work_InStatelessMode() { + // Use TCS to coordinate: the tool reports progress, then waits for the test to confirm + // the notification arrived before completing. This avoids the race where fire-and-forget + // NotifyProgressAsync hasn't flushed before the SSE stream closes. + var progressReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var toolCanComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Builder.Services.AddMcpServer() .WithHttpTransport(options => { options.Stateless = true; }) .WithTools([McpServerTool.Create( - [System.ComponentModel.Description("Reports progress")] (IProgress progress) => + async (IProgress progress) => { - for (int i = 0; i < 5; i++) - { - progress.Report(new() { Progress = i, Total = 5, Message = $"Step {i}" }); - } + progress.Report(new() { Progress = 0, Total = 1, Message = "Working" }); + await toolCanComplete.Task; return "complete"; }, new() { Name = "progressTool" })]); @@ -217,23 +220,21 @@ public async Task ProgressNotifications_Work_InStatelessMode() await using var client = await ConnectMcpClientAsync(); - var progressMessages = new ConcurrentBag(); - var toolResponse = await client.CallToolAsync( + // Use a custom IProgress that sets the TCS synchronously (no thread pool posting). + var callTask = client.CallToolAsync( "progressTool", - progress: new Progress(p => progressMessages.Add(p.Message!)), + progress: new SynchronousProgress(_ => progressReceived.TrySetResult()), cancellationToken: TestContext.Current.CancellationToken); + // Wait for the progress notification to arrive at the client. + await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // Let the tool complete now that we've confirmed progress was received. + toolCanComplete.SetResult(); + + var toolResponse = await callTask; var content = Assert.Single(toolResponse.Content); Assert.Equal("complete", Assert.IsType(content).Text); - // Progress posts callbacks to the thread pool asynchronously, so we need to wait - // briefly for them to fire after CallToolAsync returns the tool response. - var sw = Stopwatch.StartNew(); - while (progressMessages.IsEmpty && sw.Elapsed < TimeSpan.FromSeconds(5)) - { - await Task.Delay(50, TestContext.Current.CancellationToken); - } - - Assert.NotEmpty(progressMessages); } [Fact] @@ -350,4 +351,9 @@ public class ScopedService { public string? State { get; set; } } + + private class SynchronousProgress(Action handler) : IProgress + { + public void Report(T value) => handler(value); + } } From c9e8ea910b3292e56d10078290f3f5a659d1e86d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 25 Mar 2026 18:57:22 -0700 Subject: [PATCH 04/42] Add decision tree, backpressure warning, and normalize xrefs Add quick stateless-vs-stateful decision guide and explain why stateless is recommended but not the default. Document the lack of handler backpressure as a deployment footgun for stateful mode. Normalize cross-doc links to use xref instead of relative paths. Also document stale HttpContext risk with SSE transport. --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/httpcontext/httpcontext.md | 13 +++++++++++++ docs/concepts/logging/logging.md | 2 +- docs/concepts/sessions/sessions.md | 16 ++++++++++++++++ docs/concepts/transports/transports.md | 4 ++-- .../StatelessServerTests.cs | 7 ++++++- 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index c6e2168ee..ce89397db 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici ### URL Elicitation Required Error -When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](../sessions/sessions.md) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. +When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:sessions) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. #### Throwing UrlElicitationRequiredException on the Server diff --git a/docs/concepts/httpcontext/httpcontext.md b/docs/concepts/httpcontext/httpcontext.md index 7fc408835..915377565 100644 --- a/docs/concepts/httpcontext/httpcontext.md +++ b/docs/concepts/httpcontext/httpcontext.md @@ -29,3 +29,16 @@ The following code snippet shows the `ContextTools` class accepting an [IHttpCon and the `GetHttpHeaders` method accessing the current [HttpContext] to retrieve the HTTP headers from the current request. [!code-csharp[](samples/Tools/ContextTools.cs?name=snippet_AccessHttpContext)] + +### SSE transport and stale HttpContext + +When using the legacy SSE transport, be aware that the `HttpContext` returned by `IHttpContextAccessor` references the long-lived SSE connection request — not the individual `POST` request that triggered the tool call. This means: + +- The `HttpContext.User` may contain stale claims if the client's token was refreshed after the SSE connection was established. +- Request headers, query strings, and other per-request metadata will reflect the initial SSE connection, not the current operation. + +The Streamable HTTP transport does not have this issue because each tool call is its own HTTP request, so `IHttpContextAccessor.HttpContext` always reflects the current request. In [stateless](xref:sessions) mode, this is guaranteed since every request creates a fresh server context. + + +> [!NOTE] +> The server validates that the user identity has not changed between the session-initiating request and subsequent requests (using the `sub`, `NameIdentifier`, or `UPN` claim). If the user identity changes, the request is rejected with `403 Forbidden`. However, other claims (roles, permissions, custom claims) are not re-validated and may become stale over the lifetime of a session. diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index 2c307ac42..90f2fc0d9 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -46,7 +46,7 @@ MCP servers that implement the Logging utility must declare this in the capabili [Initialization]: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server -to send log messages—only allows it. Note that [stateless](../sessions/sessions.md) MCP servers might not be capable of sending log +to send log messages—only allows it. Note that [stateless](xref:sessions) MCP servers might not be capable of sending log messages as there might not be an open connection to the client on which the log messages could be sent. The C# SDK provides an extension method on to allow the diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 1a480dcb8..b8393e743 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -11,6 +11,16 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso [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 (sampling, elicitation, roots)? → **Use stateful.** +- Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** +- Otherwise → **Use stateless** (`options.Stateless = true`). + + +> [!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](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. + ## 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. @@ -115,6 +125,12 @@ You can mitigate this with and 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. + ## 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. diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 5c1413eaf..d556d9238 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -131,7 +131,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](../sessions/sessions.md) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: @@ -204,6 +204,6 @@ No additional configuration is needed. When a client connects using the SSE prot | Process model | Child process | Remote HTTP | Remote HTTP | | Direction | Bidirectional | Bidirectional | Server→client stream + client→server POST | | Session resumption | N/A | ✓ (stateful mode) | ✗ | -| Stateless mode | N/A | ✓ ([recommended](../sessions/sessions.md)) | ✗ | +| Stateless mode | N/A | ✓ ([recommended](xref:sessions)) | ✗ | | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools | Remote servers | Legacy compatibility | diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 3cc8e53ed..131adcdf2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -227,7 +227,7 @@ public async Task ProgressNotifications_Work_InStatelessMode() cancellationToken: TestContext.Current.CancellationToken); // Wait for the progress notification to arrive at the client. - await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await progressReceived.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); // Let the tool complete now that we've confirmed progress was received. toolCanComplete.SetResult(); @@ -267,6 +267,11 @@ public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode() HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + // Two separate McpClient instances are needed because the X-Tool-Suffix header is set on + // the shared HttpClient before connecting. Each McpClient captures the headers at connect + // time, so changing headers between clients proves ConfigureSessionOptions sees different + // request data on each HTTP request. + // First request with "alpha" — proves ConfigureSessionOptions runs and configures the tool. HttpClient.DefaultRequestHeaders.Add("X-Tool-Suffix", "alpha"); From 7e703f3efb67c77072e9e0ba7839ba315a397d77 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 10:03:56 -0700 Subject: [PATCH 05/42] Explicitly set Stateless in all WithHttpTransport calls 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> --- docs/concepts/completions/completions.md | 2 +- docs/concepts/elicitation/samples/server/Program.cs | 7 +++++-- docs/concepts/filters.md | 4 ++-- docs/concepts/logging/samples/server/Program.cs | 7 +++++-- docs/concepts/pagination/pagination.md | 2 +- docs/concepts/prompts/prompts.md | 2 +- docs/concepts/resources/resources.md | 6 ++++-- docs/concepts/sessions/sessions.md | 4 ++++ docs/concepts/tasks/tasks.md | 4 ++-- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 2 +- samples/AspNetCoreMcpPerSessionTools/Program.cs | 4 ++++ samples/AspNetCoreMcpPerSessionTools/README.md | 3 +++ samples/AspNetCoreMcpServer/Program.cs | 8 ++++---- samples/EverythingServer/Program.cs | 4 ++++ samples/LongRunningTasks/Program.cs | 2 +- 16 files changed, 43 insertions(+), 20 deletions(-) diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 10996882c..7570d27ed 100644 --- a/docs/concepts/completions/completions.md +++ b/docs/concepts/completions/completions.md @@ -26,7 +26,7 @@ Register a completion handler when building the server. The handler receives a r ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithPrompts() .WithResources() .WithCompleteHandler(async (ctx, ct) => diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs index 8c6862464..b10dd7e74 100644 --- a/docs/concepts/elicitation/samples/server/Program.cs +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -6,8 +6,11 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => - options.IdleTimeout = Timeout.InfiniteTimeSpan // Never timeout - ) + { + // Elicitation requires stateful mode because it sends server-to-client requests. + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; + }) .WithTools(); builder.Logging.AddConsole(options => diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 21b85392b..fefaeb55e 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -401,7 +401,7 @@ To enable authorization support, call `AddAuthorizationFilters()` when configuri ```csharp services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` @@ -501,7 +501,7 @@ This allows you to implement logging, metrics, or other cross-cutting concerns t ```csharp services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithRequestFilters(requestFilters => { requestFilters.AddListToolsFilter(next => async (context, cancellationToken) => diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs index 7de039e09..48e2905c2 100644 --- a/docs/concepts/logging/samples/server/Program.cs +++ b/docs/concepts/logging/samples/server/Program.cs @@ -6,8 +6,11 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => - options.IdleTimeout = Timeout.InfiniteTimeSpan // Never timeout - ) + { + // Log streaming requires stateful mode because the server pushes log notifications + // to clients. Set Stateless = false explicitly for forward compatibility. + options.Stateless = false; + }) .WithTools(); // .WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult()); diff --git a/docs/concepts/pagination/pagination.md b/docs/concepts/pagination/pagination.md index 1249acbf5..48e485f6a 100644 --- a/docs/concepts/pagination/pagination.md +++ b/docs/concepts/pagination/pagination.md @@ -70,7 +70,7 @@ When implementing custom list handlers on the server, pagination is supported by ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithListResourcesHandler(async (ctx, ct) => { const int pageSize = 10; diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 08ceaf9c0..4dba463a1 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -63,7 +63,7 @@ Register prompt types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithPrompts() .WithPrompts(); ``` diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index c7e713c51..c5e2a8d08 100644 --- a/docs/concepts/resources/resources.md +++ b/docs/concepts/resources/resources.md @@ -74,7 +74,7 @@ Register resource types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithResources() .WithResources(); ``` @@ -208,7 +208,9 @@ Register subscription handlers when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + // Subscriptions require stateful mode because the server pushes change notifications + // to clients. Set Stateless = false explicitly for forward compatibility. + .WithHttpTransport(o => o.Stateless = false) .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index b8393e743..cdf4494e5 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -303,6 +303,8 @@ For high-availability deployments, { + // Session migration is a stateful-mode feature. + options.Stateless = false; options.SessionMigrationHandler = new MySessionMigrationHandler(); }); ``` @@ -329,6 +331,8 @@ The server can store SSE events for replay when clients reconnect using the `Las builder.Services.AddMcpServer() .WithHttpTransport(options => { + // Session resumability is a stateful-mode feature. + options.Stateless = false; options.EventStreamStore = new MyEventStreamStore(); }); ``` diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 1947d210b..c0b571f77 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -64,7 +64,7 @@ builder.Services.AddMcpServer(options => // Enable tasks by providing a task store options.TaskStore = taskStore; }) -.WithHttpTransport() +.WithHttpTransport(o => o.Stateless = true) .WithTools(); ``` @@ -566,7 +566,7 @@ builder.Services.AddMcpServer(options => { options.TaskStore = taskStore; }) -.WithHttpTransport() +.WithHttpTransport(o => o.Stateless = true) .WithTools(); ``` diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 6ac2f9a5e..1fb62d651 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -39,7 +39,7 @@ Register the tool type when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithTools(); ``` diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index d556d9238..b19f6ff5f 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -184,7 +184,7 @@ The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. T var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = true) .WithTools(); var app = builder.Build(); diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index b9174cd7a..983d296f2 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -13,6 +13,10 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { + // This sample demonstrates per-session tool filtering, which requires stateful mode. + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; + // Configure per-session options to filter tools based on route category options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { diff --git a/samples/AspNetCoreMcpPerSessionTools/README.md b/samples/AspNetCoreMcpPerSessionTools/README.md index 8e3665100..e0d968042 100644 --- a/samples/AspNetCoreMcpPerSessionTools/README.md +++ b/samples/AspNetCoreMcpPerSessionTools/README.md @@ -65,6 +65,9 @@ The key technique is using `ConfigureSessionOptions` to modify the tool collecti ```csharp .WithHttpTransport(options => { + // Per-session tool filtering requires stateful mode. Set Stateless = false + // explicitly for forward compatibility in case the default changes. + options.Stateless = false; options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { var toolCategory = GetToolCategoryFromRoute(httpContext); diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 41df0fc0b..3b15d07af 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -7,11 +7,11 @@ var builder = WebApplication.CreateBuilder(args); // Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send -// a server-to-client sampling request. This requires stateful (session-based) mode, which -// is the default. See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions for details -// on when to prefer stateless mode instead. +// a server-to-client sampling request. This requires stateful (session-based) mode. Set +// Stateless = false explicitly for forward compatibility in case the default changes. +// See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for details. builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(o => o.Stateless = false) .WithTools() .WithTools() .WithTools() diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index 5a0c36aec..9ac979766 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -57,6 +57,10 @@ }) .WithHttpTransport(options => { + // This sample uses subscriptions, SampleLlmTool (sampling), and RunSessionHandler. + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; + // Add a RunSessionHandler to remove all subscriptions for the session when it ends #pragma warning disable MCPEXP002 // RunSessionHandler is experimental options.RunSessionHandler = async (httpContext, mcpServer, token) => diff --git a/samples/LongRunningTasks/Program.cs b/samples/LongRunningTasks/Program.cs index b1817676e..ee9174554 100644 --- a/samples/LongRunningTasks/Program.cs +++ b/samples/LongRunningTasks/Program.cs @@ -26,7 +26,7 @@ Version = "1.0.0" }; }) -.WithHttpTransport() +.WithHttpTransport(o => o.Stateless = true) .WithTools(); var app = builder.Build(); From 20c6816922d2d07d87c18482ed9e43c0d8b32b13 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 10:24:28 -0700 Subject: [PATCH 06/42] Document DI service lifetimes and scoping behavior 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> --- docs/concepts/sessions/sessions.md | 70 ++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index cdf4494e5..98dce54c1 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -15,6 +15,8 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** - Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** +- 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.** - Otherwise → **Use stateless** (`options.Stateless = true`). @@ -91,7 +93,7 @@ When - Server-to-client requests (sampling, elicitation, roots) via an open SSE stream - Unsolicited notifications (resource updates, logging messages) - Resource subscriptions -- Session-scoped state (e.g., per-session DI scopes, RunSessionHandler) +- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) ### When to use stateful mode @@ -101,7 +103,10 @@ Use stateful mode when your server needs one or more of: - **Unsolicited notifications**: Sending resource-changed notifications or log messages without a preceding client request - **Resource subscriptions**: Clients subscribing to resource changes and receiving updates - **Session-scoped state**: Logic that must persist across multiple requests within the same session -- **Debugging stdio servers over HTTP**: When you want to test a typically stateful stdio server over HTTP while supporting concurrent connections from editors like Claude Code, GitHub Copilot in VS Code, Cursor, etc., sessions let you distinguish between them +- **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 footguns](#deployment-footguns) below are 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 largely irrelevant, and sessions provide the richest feature set with no practical downsides. ### Deployment footguns @@ -131,13 +136,22 @@ The SDK does not limit how long a handler can run or how many requests can be pr Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response. This means Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally. The and 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. +### Convenience pitfalls of statelessness + +Stateless mode trades features for simplicity. Before choosing it, consider what you give up: + +- **No server-to-client requests.** Sampling, elicitation, and roots all require the server to send a JSON-RPC request back to the client over a persistent connection. Stateless mode has no such connection. The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to solve this, but it is not yet available. +- **No push notifications.** The server cannot send unsolicited messages — log entries, resource-change events, or progress updates outside the scope of a tool call response. Every notification must be part of a direct response to a client request. +- **No concurrent client isolation.** Every request is independent. The server cannot 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.** When a client disconnects and reconnects (e.g., an editor restarting), stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start — because there was never a session to begin with. If your server holds any external state, you must manage cleanup through other means. + ## 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. +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). ## Session lifecycle (HTTP) @@ -279,6 +293,53 @@ builder.Services.AddMcpServer() .WithTools(); ``` +## Service lifetimes and DI scopes + +How the server resolves scoped services depends on the transport and session mode. The property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. + +### Stateful HTTP + +In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new async scope via `IServiceScopeFactory.CreateAsyncScope()`. The scoped `IServiceProvider` is available on . + +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 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 is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. + +You can set to `false` if you want handlers to resolve services directly from the root container. This can be useful for performance-sensitive scenarios where scope creation overhead matters, but be aware that scoped services will then behave like singletons for the lifetime of the process. + +```csharp +builder.Services.AddMcpServer(options => +{ + // Disable per-handler scoping. Scoped services will resolve from the root container. + options.ScopeRequests = false; +}) +.WithStdioServerTransport() +.WithTools(); +``` + +### Summary + +| Mode | Service provider | ScopeRequests | Handler scope | +|------|-----------------|---------------|---------------| +| **Stateful HTTP** | Application services | `true` (default) | New async scope per handler invocation | +| **Stateless HTTP** | `HttpContext.RequestServices` | `false` (forced) | Shared HTTP request scope | +| **stdio** | Application services | `true` (default, configurable) | New async scope per handler invocation | + ## 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. @@ -357,3 +418,6 @@ This is useful for clients that may experience transient network issues. Without | **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all clients | Requires clients to track and send `Mcp-Session-Id` | +| **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 | From ad142379fd87ac97f9db6d3cb53fbbe2db2f2c58 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 10:46:07 -0700 Subject: [PATCH 07/42] Reorganize sessions.md for better flow 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> --- docs/concepts/sessions/sessions.md | 148 ++++++++++++++--------------- 1 file changed, 71 insertions(+), 77 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 98dce54c1..6f8a2bc67 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -76,6 +76,15 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, > [!TIP] > If you're unsure whether you need sessions, start with stateless mode. You can always switch to stateful mode later if you need server-to-client requests or other session features. +### What you give up with stateless mode + +Stateless mode trades features for simplicity: + +- **No server-to-client requests.** Sampling, elicitation, and roots all require the server to send a JSON-RPC request back to the client over a persistent connection. Stateless mode has no such connection. The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to solve this, but it is not yet available. +- **No push notifications.** The server cannot send unsolicited messages — log entries, resource-change events, or progress updates outside the scope of a tool call response. Every notification must be part of a direct response to a client request. +- **No concurrent client isolation.** Every request is independent. The server cannot 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.** When a client disconnects and reconnects (e.g., an editor restarting), stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start — because there was never a session to begin with. If your server holds any external state, you must manage cleanup through other means. + ### Stateless alternatives for server-to-client interactions @@ -106,56 +115,29 @@ Use stateful mode when your server needs one or more of: - **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 footguns](#deployment-footguns) below are 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 largely irrelevant, and sessions provide the richest feature set with no practical downsides. - -### Deployment footguns - -Stateful sessions introduce several challenges that you should carefully consider: - -#### 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 and 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 may not handle gracefully. - -You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. - -#### Clients that don't send Mcp-Session-Id - -Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. - -#### No built-in backpressure on request handlers - -The SDK does not limit how long a handler can run or how many requests can be processed concurrently within a session. A misbehaving or compromised client can flood a stateful session with requests, and each request will spawn a handler that runs to completion. This can lead to thread starvation, GC pressure, or out-of-memory conditions that affect the entire HTTP server process — not just the offending session. +The [deployment considerations](#deployment-considerations) below are 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 largely irrelevant, and sessions provide the richest feature set with no practical downsides. -Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response. This means Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally. The and 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. +## Comparison -### Convenience pitfalls of statelessness - -Stateless mode trades features for simplicity. Before choosing it, consider what you give up: - -- **No server-to-client requests.** Sampling, elicitation, and roots all require the server to send a JSON-RPC request back to the client over a persistent connection. Stateless mode has no such connection. The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to solve this, but it is not yet available. -- **No push notifications.** The server cannot send unsolicited messages — log entries, resource-change events, or progress updates outside the scope of a tool call response. Every notification must be part of a direct response to a client request. -- **No concurrent client isolation.** Every request is independent. The server cannot 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.** When a client disconnects and reconnects (e.g., an editor restarting), stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start — because there was never a session to begin with. If your server holds any external state, you must manage cleanup through other means. - -## 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. +| 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** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | +| **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | +| **Resource subscriptions** | Not supported | Supported | +| **Client compatibility** | Works with all clients | Requires clients to track and send `Mcp-Session-Id` | +| **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 | -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). +## Transports and sessions -## Session lifecycle (HTTP) +### Streamable HTTP -### Creation +#### Session lifecycle A session begins when a client sends an `initialize` JSON-RPC request without an `Mcp-Session-Id` header. The server: @@ -166,14 +148,14 @@ A session begins when a client sends an `initialize` JSON-RPC request without an All subsequent requests from the client must include this session ID. -### Activity tracking +#### Activity tracking The server tracks the last activity time for each session. Activity is recorded when: - A request arrives for the session (POST, GET, or DELETE) - A response is sent for the session -### Idle timeout +#### Idle timeout Sessions that have no activity for the duration of (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds. @@ -187,7 +169,7 @@ When a session times out: You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though this is not recommended for production deployments. -### Maximum idle session count +#### Maximum idle session count (default: **10,000**) limits how many idle sessions can exist simultaneously. If this limit is exceeded: @@ -197,7 +179,7 @@ You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though Sessions with an active `GET` request (open SSE stream) don't count toward this limit. -### Termination +#### Termination Sessions can be terminated by: @@ -206,7 +188,31 @@ Sessions can be terminated by: - **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 -## Configuration reference +#### 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 and 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 may not handle gracefully. You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. + +**Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. + +**No built-in backpressure on request handlers.** The SDK does not limit how long a handler can run or how many requests can be processed concurrently within a session. A misbehaving or compromised client can flood a stateful session with requests, and each request will spawn a handler that runs to completion. This can lead to thread starvation, GC pressure, or out-of-memory conditions that affect the entire HTTP server process — not just the offending session. Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response. This means Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally. The and 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. + +### 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). + +## Server configuration + +### Configuration reference All session-related configuration is on , configured via `WithHttpTransport`: @@ -251,7 +257,7 @@ builder.Services.AddMcpServer() | | `ISseEventStreamStore?` | `null` | Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | | | `bool` | `false` | Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | -## Per-session configuration +### 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.). @@ -272,7 +278,7 @@ options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancella See the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample for a complete example that filters tools based on route parameters. -### Per-request configuration in stateless mode +#### 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: @@ -293,11 +299,11 @@ builder.Services.AddMcpServer() .WithTools(); ``` -## Service lifetimes and DI scopes +### Service lifetimes and DI scopes How the server resolves scoped services depends on the transport and session mode. The property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. -### Stateful HTTP +#### Stateful HTTP In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new async scope via `IServiceScopeFactory.CreateAsyncScope()`. The scoped `IServiceProvider` is available on . @@ -307,7 +313,7 @@ This means: - **Singleton services** resolve from the application container as usual - **Transient services** create a new instance per resolution, as usual -### Stateless HTTP +#### Stateless HTTP In stateless mode, the server uses ASP.NET Core's per-request `HttpContext.RequestServices` as its service provider, and 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. @@ -316,7 +322,7 @@ 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 +#### stdio The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. @@ -332,7 +338,7 @@ builder.Services.AddMcpServer(options => .WithTools(); ``` -### Summary +#### DI scope summary | Mode | Service provider | ScopeRequests | Handler scope | |------|-----------------|---------------|---------------| @@ -340,11 +346,13 @@ builder.Services.AddMcpServer(options => | **Stateless HTTP** | `HttpContext.RequestServices` | `false` (forced) | Shared HTTP request scope | | **stdio** | Application services | `true` (default, configurable) | New async scope per handler invocation | -## User binding +## Security + +### 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 +#### 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: @@ -356,7 +364,9 @@ When authentication is configured, the server automatically binds sessions to th 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). -## Session migration +## Advanced features + +### Session migration For high-availability deployments, 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. @@ -384,7 +394,7 @@ Implementations should: Session migration adds significant complexity. Consider whether stateless mode is a better fit for your deployment scenario. -## Session resumability +### Session resumability The server can store SSE events for replay when clients reconnect using the `Last-Event-ID` header. Configure this with : @@ -405,19 +415,3 @@ When configured: - When a client reconnects with `Last-Event-ID`, missed events are replayed before new events are sent This is useful for clients that may experience transient network issues. Without an event store, clients that disconnect and reconnect may miss events that were sent while they were disconnected. - -## Choosing stateless vs. stateful - -| 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** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | -| **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | -| **Resource subscriptions** | Not supported | Supported | -| **Client compatibility** | Works with all clients | Requires clients to track and send `Mcp-Session-Id` | -| **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 | From 2d140190186ea5672ffc46a4a3dd15d798e987b4 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 11:03:41 -0700 Subject: [PATCH 08/42] Fix idle session docs and clean up DI scope language 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> --- docs/concepts/sessions/sessions.md | 10 +++++----- .../HttpServerTransportOptions.cs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 6f8a2bc67..b173120bf 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -159,7 +159,7 @@ The server tracks the last activity time for each session. Activity is recorded Sessions that have no activity for the duration of (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 an open `GET` request (SSE stream). Sessions with an active `GET` request are never considered idle. +A client can keep its session alive by maintaining any open HTTP request (e.g., 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: @@ -177,7 +177,7 @@ You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though - 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 an active `GET` request (open SSE stream) don't count toward this limit. +Sessions with any active HTTP request don't count toward this limit. #### Termination @@ -305,7 +305,7 @@ How the server resolves scoped services depends on the transport and session mod #### Stateful HTTP -In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new async scope via `IServiceScopeFactory.CreateAsyncScope()`. The scoped `IServiceProvider` is available on . +In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. This means: @@ -342,9 +342,9 @@ builder.Services.AddMcpServer(options => | Mode | Service provider | ScopeRequests | Handler scope | |------|-----------------|---------------|---------------| -| **Stateful HTTP** | Application services | `true` (default) | New async scope per handler invocation | +| **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 async scope per handler invocation | +| **stdio** | Application services | `true` (default, configurable) | New scope per handler invocation | ## Security diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 80b89f3a4..33d5c7c1e 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -132,8 +132,8 @@ public class HttpServerTransportOptions /// /// /// Past this limit, the server logs a critical error and terminates the oldest idle sessions, even if they have not reached - /// their , until the idle session count is below this limit. Clients that keep their session open by - /// keeping a GET request open don't count towards this limit. + /// their , until the idle session count is below this limit. Sessions with any active HTTP request + /// are not considered idle and don't count towards this limit. /// public int MaxIdleSessionCount { get; set; } = 10_000; From 7f9aae05a1333b521122f3cb201ea6470cf110c6 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 11:31:55 -0700 Subject: [PATCH 09/42] Elevate backpressure warning to top-level security callout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index b173120bf..3aa82ab37 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -23,6 +23,13 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso > [!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](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. +> [!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 and 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. + ## 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. @@ -200,7 +207,7 @@ Stateful sessions introduce several challenges for production, internet-facing s **Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. -**No built-in backpressure on request handlers.** The SDK does not limit how long a handler can run or how many requests can be processed concurrently within a session. A misbehaving or compromised client can flood a stateful session with requests, and each request will spawn a handler that runs to completion. This can lead to thread starvation, GC pressure, or out-of-memory conditions that affect the entire HTTP server process — not just the offending session. Stateless mode is significantly more resilient here because each tool call is a standard HTTP request-response. This means Kestrel and IIS connection limits, request timeouts, and rate-limiting middleware all apply naturally. The and 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. +**No built-in backpressure on request handlers.** See the [warning at the top of this document](#sessions) for details on the security implications of unbounded request handling in stateful sessions. ### stdio transport From b856d843780cde93cac0c48e3b109903def45d47 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:00:30 -0700 Subject: [PATCH 10/42] Refine client compatibility and activity tracking in sessions doc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 3aa82ab37..797954e2c 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -135,7 +135,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | | **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | -| **Client compatibility** | Works with all clients | Requires clients to track and send `Mcp-Session-Id` | +| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients, but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | | **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 | @@ -159,7 +159,7 @@ All subsequent requests from the client must include this session ID. The server tracks the last activity time for each session. Activity is recorded when: -- A request arrives for the session (POST, GET, or DELETE) +- A request arrives for the session (POST or GET) - A response is sent for the session #### Idle timeout From 040d26a2b846069c701a37bdb7ebd3964cf748d9 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:19:10 -0700 Subject: [PATCH 11/42] Tone down ScopeRequests=false guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 797954e2c..7a09a5554 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -333,7 +333,7 @@ This means: The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. -You can set to `false` if you want handlers to resolve services directly from the root container. This can be useful for performance-sensitive scenarios where scope creation overhead matters, but be aware that scoped services will then behave like singletons for the lifetime of the process. +You can set to `false` if you want handlers to resolve services directly from the root container. This is a rare, advanced option — similar to how ASP.NET Core itself supports opting out of per-request scopes — and is generally not recommended because scoped services will then behave like singletons for the lifetime of the process, which most scoped registrations don't expect. ```csharp builder.Services.AddMcpServer(options => From 26e3bb52c8a6fcd33a608d24d79e578c8fa126f4 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:23:54 -0700 Subject: [PATCH 12/42] Soften sessions doc wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 7a09a5554..654ff3f79 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -122,7 +122,7 @@ Use stateful mode when your server needs one or more of: - **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) below are 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 largely irrelevant, and sessions provide the richest feature set with no practical downsides. +The [deployment considerations](#deployment-considerations) below are 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. ## Comparison From d13e0f3187e912eecbed30a77c4d5a876ebcfa7a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:39:19 -0700 Subject: [PATCH 13/42] Fix ScopeRequests guidance for pre-scoped providers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 654ff3f79..b6fb759de 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -333,12 +333,12 @@ This means: The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. -You can set to `false` if you want handlers to resolve services directly from the root container. This is a rare, advanced option — similar to how ASP.NET Core itself supports opting out of per-request scopes — and is generally not recommended because scoped services will then behave like singletons for the lifetime of the process, which most scoped registrations don't expect. +You can set to `false` if the `IServiceProvider` passed to the server is already scoped to the desired lifetime. For example, when using with a pre-scoped service provider, disabling `ScopeRequests` avoids creating redundant nested scopes. However, be cautious setting this globally via `AddMcpServer` for stdio servers — with the default hosting, the server receives the root application `IServiceProvider`, so scoped services would behave like singletons for the lifetime of the process. ```csharp builder.Services.AddMcpServer(options => { - // Disable per-handler scoping. Scoped services will resolve from the root container. + // Disable per-handler scoping — only appropriate when the server's IServiceProvider is already scoped. options.ScopeRequests = false; }) .WithStdioServerTransport() From 083cf5ba6c472d8bd75a710ec9bfd0ee73aca13b Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:46:12 -0700 Subject: [PATCH 14/42] Replace ScopeRequests sample with McpServer.Create example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index b6fb759de..0b84ae928 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -333,16 +333,21 @@ This means: The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. -You can set to `false` if the `IServiceProvider` passed to the server is already scoped to the desired lifetime. For example, when using with a pre-scoped service provider, disabling `ScopeRequests` avoids creating redundant nested scopes. However, be cautious setting this globally via `AddMcpServer` for stdio servers — with the default hosting, the server receives the root application `IServiceProvider`, so scoped services would behave like singletons for the lifetime of the process. +You can set to `false` when using with an `IServiceProvider` that is already scoped to the desired lifetime — this avoids creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: ```csharp -builder.Services.AddMcpServer(options => -{ - // Disable per-handler scoping — only appropriate when the server's IServiceProvider is already scoped. - options.ScopeRequests = false; -}) -.WithStdioServerTransport() -.WithTools(); +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 From 5735987a893d0976cb8b1f993e103ad6c8801acb Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 12:49:22 -0700 Subject: [PATCH 15/42] Split DI scopes guidance for stdio vs custom transports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 0b84ae928..e8a227912 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -333,7 +333,9 @@ This means: The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. -You can set to `false` when using with an `IServiceProvider` that is already scoped to the desired lifetime — this avoids creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: +#### McpServer.Create (custom transports) + +When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: ```csharp Pipe clientToServerPipe = new(), serverToClientPipe = new(); @@ -357,6 +359,7 @@ await using McpServer server = McpServer.Create( | **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 | ## Security From 5737a99147f8cfd4e06753541b81f6c4af2998a1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 13:24:09 -0700 Subject: [PATCH 16/42] Fix docfx xref namespaces for McpServer.Services and McpServer.Create Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index e8a227912..d7ac5c0b7 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -312,7 +312,7 @@ How the server resolves scoped services depends on the transport and session mod #### Stateful HTTP -In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. +In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. This means: @@ -331,11 +331,11 @@ This means: #### stdio -The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. +The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, 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 , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: +When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: ```csharp Pipe clientToServerPipe = new(), serverToClientPipe = new(); From 6dd7a5cdef8f88b07629a08309b801ed16714afc Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 13:30:36 -0700 Subject: [PATCH 17/42] Update InMemoryTransport link to pinned lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index d7ac5c0b7..e7761be4a 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -335,7 +335,7 @@ The stdio transport creates a single server for the lifetime of the process. The #### McpServer.Create (custom transports) -When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/InMemoryTransport) shows a minimal example of using `McpServer.Create` with in-memory pipes: +When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set 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(); From 9e85bd75df1ccf2d31724028b2c33d31171a2b53 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 14:12:03 -0700 Subject: [PATCH 18/42] Spruce up transports.md with per-mode comparison table 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> --- docs/concepts/transports/transports.md | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index b19f6ff5f..3576e943c 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -178,13 +178,17 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. The same `MapMcp()` endpoint handles both protocols — clients connecting with SSE are automatically served using the legacy SSE mechanism: +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. The same `MapMcp()` endpoint handles both protocols — clients connecting with SSE are automatically served using the legacy SSE mechanism. SSE requires stateful mode (the default); legacy SSE endpoints are not mapped when `Stateless = true`. ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(options => + { + // SSE requires stateful mode (the default). Set explicitly for clarity. + options.Stateless = false; + }) .WithTools(); var app = builder.Build(); @@ -199,11 +203,16 @@ No additional configuration is needed. When a client connects using the SSE prot ### Transport mode comparison -| Feature | stdio | Streamable HTTP | SSE (Legacy) | -|---------|-------|----------------|--------------| -| Process model | Child process | Remote HTTP | Remote HTTP | -| Direction | Bidirectional | Bidirectional | Server→client stream + client→server POST | -| Session resumption | N/A | ✓ (stateful mode) | ✗ | -| Stateless mode | N/A | ✓ ([recommended](xref:sessions)) | ✗ | -| Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | -| Best for | Local tools | Remote servers | Legacy compatibility | +| Feature | stdio | Streamable HTTP (stateless) | Streamable HTTP (stateful) | SSE (legacy, stateful) | +|---------|-------|-----------------------------|----------------------------|--------------| +| Process model | Child process | Remote HTTP | Remote HTTP | Remote HTTP | +| Direction | Bidirectional | Request-response | Bidirectional | Server→client stream + client→server POST | +| Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | `Mcp-Session-Id` tracked in memory | +| Server-to-client requests | ✓ | ✗ (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458)) | ✓ | ✓ | +| Unsolicited notifications | ✓ | ✗ | ✓ | ✓ | +| Session resumption | N/A | N/A | ✓ | ✗ | +| Horizontal scaling | N/A | No constraints | Requires session affinity | Requires session affinity | +| Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | +| Best for | Local tools, IDE integrations | Remote servers, production deployments | Local HTTP debugging, server-to-client features | Legacy client compatibility | + +For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:sessions). From b9fe994ddb48d1eb261a60119b5c70b554791648 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 14:17:05 -0700 Subject: [PATCH 19/42] Add in-memory transport section and fix SSE comment wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 3576e943c..195d3ec0c 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -186,7 +186,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // SSE requires stateful mode (the default). Set explicitly for clarity. + // SSE requires stateful mode (the default). Set explicitly for forward compatibility. options.Stateless = false; }) .WithTools(); @@ -216,3 +216,35 @@ No additional configuration is needed. When a client connects using the SSE prot | Best for | Local tools, IDE integrations | Remote servers, production deployments | Local HTTP debugging, server-to-client features | Legacy client compatibility | For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:sessions). + +### In-memory transport + +The and types work with any `Stream`, including in-memory pipes. This is useful for testing, embedding an MCP server in a larger application, or running a client and server in the same process without network overhead. + +The following example creates a client and server connected via `System.IO.Pipelines` (from the [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/blob/51a4fde4d9cfa12ef9430deef7daeaac36625be8/samples/InMemoryTransport/Program.cs)): + +```csharp +using ModelContextProtocol.Client; +using ModelContextProtocol.Server; +using System.IO.Pipelines; + +Pipe clientToServerPipe = new(), serverToClientPipe = new(); + +// Create a server using a stream-based transport over an in-memory pipe. +await using McpServer server = McpServer.Create( + new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), + new McpServerOptions + { + ToolCollection = [McpServerTool.Create((string message) => $"Echo: {message}", new() { Name = "echo" })] + }); +_ = server.RunAsync(); + +// Connect a client using a stream-based transport over the same in-memory pipe. +await using McpClient client = await McpClient.CreateAsync( + new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); + +// List and invoke tools. +var tools = await client.ListToolsAsync(); +var echo = tools.First(t => t.Name == "echo"); +Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); +``` From bed9aec2c944e2927771b3537116aedcebab4b38 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 14:20:58 -0700 Subject: [PATCH 20/42] Fix SSE session ID mechanism in transport comparison table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 195d3ec0c..d27e13cbb 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -207,7 +207,7 @@ No additional configuration is needed. When a client connects using the SSE prot |---------|-------|-----------------------------|----------------------------|--------------| | Process model | Child process | Remote HTTP | Remote HTTP | Remote HTTP | | Direction | Bidirectional | Request-response | Bidirectional | Server→client stream + client→server POST | -| Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | `Mcp-Session-Id` tracked in memory | +| Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | Session ID via query string, tracked in memory | | Server-to-client requests | ✓ | ✗ (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458)) | ✓ | ✓ | | Unsolicited notifications | ✓ | ✗ | ✓ | ✓ | | Session resumption | N/A | N/A | ✓ | ✗ | From e1625cc2a4e06e771f2cae05e35855dfc630bfbf Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 14:45:44 -0700 Subject: [PATCH 21/42] Fix StreamClientTransport xref namespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index d27e13cbb..b51457d4d 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -219,7 +219,7 @@ For a detailed comparison of stateless vs. stateful mode — including deploymen ### In-memory transport -The and types work with any `Stream`, including in-memory pipes. This is useful for testing, embedding an MCP server in a larger application, or running a client and server in the same process without network overhead. +The and types work with any `Stream`, including in-memory pipes. This is useful for testing, embedding an MCP server in a larger application, or running a client and server in the same process without network overhead. The following example creates a client and server connected via `System.IO.Pipelines` (from the [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/blob/51a4fde4d9cfa12ef9430deef7daeaac36625be8/samples/InMemoryTransport/Program.cs)): From 5f0051eb6f661e175225dd45f6dbf5e8e2659bc6 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 14:57:46 -0700 Subject: [PATCH 22/42] Apply suggestion from @halter73 --- docs/concepts/transports/transports.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index b51457d4d..328f586a8 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -225,6 +225,7 @@ The following example creates a client and server connected via `System.IO.Pipel ```csharp using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; From 841a3aa24073ede09e7af8c61db9a99c6e62cf14 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 15:17:57 -0700 Subject: [PATCH 23/42] Add legacy SSE transport coverage to sessions.md 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> --- docs/concepts/sessions/sessions.md | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index e7761be4a..e4c517258 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -157,14 +157,14 @@ All subsequent requests from the client must include this session ID. #### Activity tracking -The server tracks the last activity time for each session. Activity is recorded when: +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 -Sessions that have no activity for the duration of (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds. +Streamable HTTP sessions that have no activity for the duration of (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 (e.g., a long-running POST with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle. @@ -178,7 +178,7 @@ You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though #### Maximum idle session count - (default: **10,000**) limits how many idle sessions can exist simultaneously. If this limit is exceeded: + (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) @@ -188,7 +188,7 @@ Sessions with any active HTTP request don't count toward this limit. #### Termination -Sessions can be terminated by: +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 @@ -209,6 +209,29 @@ Stateful sessions introduce several challenges for production, internet-facing s **No built-in backpressure on request handlers.** See the [warning at the top of this document](#sessions) for details on the security implications of unbounded request handling in stateful sessions. +### SSE (legacy) + +The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are only mapped when `Stateless = false` (the default), because the GET and POST requests must be handled by the same server process sharing in-memory session state. + +#### 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 + + and 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`. + ### 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. From 04d825d65c444cfee4dbec30b21d7f9ca95e5cb3 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 15:34:49 -0700 Subject: [PATCH 24/42] Add cancellation and disposal section to sessions.md 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> --- docs/concepts/sessions/sessions.md | 46 ++++++++++++++++++++++++++ docs/concepts/transports/transports.md | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index e4c517258..cfcb5e07d 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -384,6 +384,52 @@ await using McpServer server = McpServer.Create( | **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 receives 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 (e.g., host shutdown via Ctrl+C). | + +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`. The handler receives an `OperationCanceledException` like any other cancellation. + +- The `initialize` request cannot be cancelled (per the MCP specification) +- 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 + +### 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 (e.g., 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 (e.g., `SIGTERM`, `Ctrl+C`, `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`, `Ctrl+C`): 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 are typically invisible at default log levels but may 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. + ## Security ### User binding diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 328f586a8..b4abc7067 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -131,7 +131,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode and how to configure session options. +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:sessions#cancellation-and-disposal) behavior during shutdown. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: From 4c9c8fbb54edcacb486d7105cbcd05f78068f9bc Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 15:38:00 -0700 Subject: [PATCH 25/42] Reorder sessions.md: practical sections first, deep-dives last 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> --- docs/concepts/sessions/sessions.md | 48 ++++++++++++++---------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index cfcb5e07d..788832062 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -328,12 +328,28 @@ builder.Services.AddMcpServer() }) .WithTools(); ``` +## Security + +### 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` -### Service lifetimes and DI scopes +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 property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. -#### Stateful HTTP +### Stateful HTTP In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. @@ -343,7 +359,7 @@ This means: - **Singleton services** resolve from the application container as usual - **Transient services** create a new instance per resolution, as usual -#### Stateless HTTP +### Stateless HTTP In stateless mode, the server uses ASP.NET Core's per-request `HttpContext.RequestServices` as its service provider, and 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. @@ -352,11 +368,11 @@ 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 +### stdio The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. -#### McpServer.Create (custom transports) +### McpServer.Create (custom transports) When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set 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: @@ -375,7 +391,7 @@ await using McpServer server = McpServer.Create( serviceProvider: scope.ServiceProvider); ``` -#### DI scope summary +### DI scope summary | Mode | Service provider | ScopeRequests | Handler scope | |------|-----------------|---------------|---------------| @@ -383,7 +399,6 @@ await using McpServer server = McpServer.Create( | **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 receives 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. @@ -429,25 +444,6 @@ For stateless servers, shutdown is even simpler: each request is independent, so ### 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 are typically invisible at default log levels but may 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. - -## Security - -### 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). - ## Advanced features ### Session migration From b8f4034ab24578bb6a566e5bf8b7821994792db2 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 15:41:12 -0700 Subject: [PATCH 26/42] Apply suggestion --- docs/concepts/sessions/sessions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 788832062..7a2b3a48a 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -399,9 +399,10 @@ await using McpServer server = McpServer.Create( | **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 receives 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. +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 From 93eecaaf0288702147ab479d2cdc96e54c145926 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 16:04:05 -0700 Subject: [PATCH 27/42] Add tasks, cancellation, and observability coverage to sessions doc 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> --- docs/concepts/sessions/sessions.md | 62 +++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 7a2b3a48a..8219e461d 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -15,6 +15,7 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** - Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** +- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful.** (Legacy SSE endpoints are disabled in stateless mode.) - 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.** - Otherwise → **Use stateless** (`options.Stateless = true`). @@ -58,12 +59,13 @@ When - 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 - 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 the legacy `/sse` endpoint is disabled +- The `GET` and `DELETE` MCP endpoints are not mapped, and the [legacy SSE endpoints](#sse-legacy) (`/sse` and `/message`) are disabled — clients that only support the legacy SSE transport cannot connect - **Server-to-client requests are disabled**, including: - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) - [Roots](xref:roots) (`RequestRootsAsync`) - Unsolicited server-to-client notifications (e.g., resource update notifications, logging messages) are not supported +- [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. @@ -118,6 +120,7 @@ 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**: Sending resource-changed notifications or log messages without a preceding client request - **Resource subscriptions**: Clients subscribing to resource changes and receiving updates +- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) (the `/sse` and `/message` endpoints are only available in stateful mode) - **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. @@ -139,6 +142,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **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 | ## Transports and sessions @@ -328,6 +332,7 @@ builder.Services.AddMcpServer() }) .WithTools(); ``` + ## Security ### User binding @@ -345,6 +350,7 @@ When authentication is configured, the server automatically binds sessions to th 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 property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. @@ -422,6 +428,7 @@ In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific - The `initialize` request cannot be cancelled (per the MCP specification) - 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 @@ -445,6 +452,59 @@ For stateless servers, shutdown is even simpler: each request is independent, so ### 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 are typically invisible at default log levels but may 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 configured (`McpServerOptions.TaskStore`), 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` or `tasks/result` 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` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. + +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 backed by an external store. See [Fault-tolerant task implementations](xref:tasks#fault-tolerant-task-implementations) for guidance. + +#### 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`. + +### Observability + +The SDK automatically integrates with [.NET's OpenTelemetry support](https://learn.microsoft.com/dotnet/core/diagnostics/distributed-tracing) and attaches session metadata to traces and metrics. + +#### Activity tags + +Every server-side request activity is tagged with `mcp.session.id` — the session's unique identifier. In stateless mode, this tag is `null` because there is no persistent session. 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 tags to filter and correlate traces by session 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. + +#### Distributed tracing + +The SDK propagates [W3C trace context](https://www.w3.org/TR/trace-context/) (`traceparent` / `tracestate`) through JSON-RPC messages via the `_meta` field. This means a client's tool call and the server's handling of that call appear as parent-child spans in a distributed trace, regardless of transport. + ## Advanced features ### Session migration From 4763a04eb883b26e44973cc2f630eeb9ef850ea1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 16:56:44 -0700 Subject: [PATCH 28/42] Apply suggestion --- docs/concepts/sessions/sessions.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 8219e461d..f370dca94 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -425,7 +425,6 @@ Stateless mode has the simplest cancellation story: the handler's `CancellationT 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`. The handler receives an `OperationCanceledException` like any other cancellation. -- The `initialize` request cannot be cancelled (per the MCP specification) - 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. From c8159bb8caa8dee5fbd5c749c3b4adfc8813e4bd Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 17:19:03 -0700 Subject: [PATCH 29/42] Refine backpressure warning with technical nuance 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> --- docs/concepts/sessions/sessions.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index f370dca94..6877bbb2b 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -25,11 +25,13 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso > **Why isn't stateless the default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. > [!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. +> **Stateful sessions require additional protections for public internet deployments.** In stateful mode, handler cancellation tokens are linked to the **session** lifetime, not the individual HTTP request. This means a client can disconnect from a POST request while the handler continues running — and there is no built-in limit on how many concurrent handlers a session can have. A misbehaving client can flood a session with requests, and each one spawns a handler that runs until it completes or the session is terminated. This can lead to thread starvation, memory pressure, or resource exhaustion that affects the entire server process. > -> 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. +> This is more exposed than comparable ASP.NET Core protocols. SignalR limits concurrent hub invocations per client (`MaximumParallelInvocationsPerClient`, default: **1**). gRPC unary calls are bounded by HTTP/2 `MaxStreamsPerConnection` (default: **100**). MCP dispatches each incoming JSON-RPC message as a fire-and-forget background task with no concurrency limit, so work accumulates without any built-in backpressure. > -> 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 and 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. +> **Stateless mode avoids this entirely.** In stateless mode, each handler's lifetime is the HTTP request's lifetime — `McpServer.DisposeAsync()` awaits all handlers before the POST response completes. This means Kestrel's connection limits, HTTP/2 `MaxStreamsPerConnection` (default: 100), request timeouts, and rate-limiting middleware all apply naturally. +> +> If you must deploy a stateful server to the public internet, apply **HTTP rate-limiting middleware**, **reverse proxy limits**, and consider **process-level isolation** (one process or container per user/session) so a single abusive session cannot starve the service. and help with non-malicious overuse (buggy clients creating too many sessions) but are not a substitute for request-level protections. ## Stateless mode (recommended) From 22b4dc1b2c6b68151cd7803e1ad640725c510460 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 17:55:30 -0700 Subject: [PATCH 30/42] Move backpressure analysis from top-level warning to detailed section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/concepts/sessions/sessions.md | 67 +++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 6877bbb2b..dfb8268d7 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -24,15 +24,6 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso > [!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](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. -> [!WARNING] -> **Stateful sessions require additional protections for public internet deployments.** In stateful mode, handler cancellation tokens are linked to the **session** lifetime, not the individual HTTP request. This means a client can disconnect from a POST request while the handler continues running — and there is no built-in limit on how many concurrent handlers a session can have. A misbehaving client can flood a session with requests, and each one spawns a handler that runs until it completes or the session is terminated. This can lead to thread starvation, memory pressure, or resource exhaustion that affects the entire server process. -> -> This is more exposed than comparable ASP.NET Core protocols. SignalR limits concurrent hub invocations per client (`MaximumParallelInvocationsPerClient`, default: **1**). gRPC unary calls are bounded by HTTP/2 `MaxStreamsPerConnection` (default: **100**). MCP dispatches each incoming JSON-RPC message as a fire-and-forget background task with no concurrency limit, so work accumulates without any built-in backpressure. -> -> **Stateless mode avoids this entirely.** In stateless mode, each handler's lifetime is the HTTP request's lifetime — `McpServer.DisposeAsync()` awaits all handlers before the POST response completes. This means Kestrel's connection limits, HTTP/2 `MaxStreamsPerConnection` (default: 100), request timeouts, and rate-limiting middleware all apply naturally. -> -> If you must deploy a stateful server to the public internet, apply **HTTP rate-limiting middleware**, **reverse proxy limits**, and consider **process-level isolation** (one process or container per user/session) so a single abusive session cannot starve the service. and help with non-malicious overuse (buggy clients creating too many sessions) but are not a substitute for request-level protections. - ## 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. @@ -213,7 +204,7 @@ Stateful sessions introduce several challenges for production, internet-facing s **Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. -**No built-in backpressure on request handlers.** See the [warning at the top of this document](#sessions) for details on the security implications of unbounded request handling in stateful sessions. +**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 and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. ### SSE (legacy) @@ -479,6 +470,62 @@ The MCP specification defines two distinct cancellation mechanisms: 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. The key factor is whether the HTTP POST response stays open while the handler runs — because when it does, HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits how many concurrent handlers a single client connection can drive. + +#### 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 +- Kestrel's `MaxStreamsPerConnection` (default: **100**) 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. + +#### With 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 (experimental) + +[Tasks](xref:tasks) are an experimental feature that enables 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 , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — 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 has the strongest backpressure story. Each handler's lifetime is the HTTP request's lifetime — `McpServer.DisposeAsync()` awaits all in-flight handlers before the POST response completes. This means Kestrel's connection limits, HTTP/2 `MaxStreamsPerConnection`, request timeouts, and rate-limiting middleware all apply naturally — identical to a standard ASP.NET Core minimal API or controller action. + +#### Summary + +| Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | +|---|---|---|---| +| **Stateless** | Yes (handler = request) | HTTP/2 streams + Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | +| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams | `MaxStreamsPerConnection` (default: 100) | +| **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 automatically integrates with [.NET's OpenTelemetry support](https://learn.microsoft.com/dotnet/core/diagnostics/distributed-tracing) and attaches session metadata to traces and metrics. From b5a47b5f9f7d9ccfb986b62f617743851f4261f0 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 18:10:25 -0700 Subject: [PATCH 31/42] Add SSE to request backpressure section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/concepts/sessions/sessions.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index dfb8268d7..5a23fbe33 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -486,6 +486,14 @@ One difference from gRPC: handler cancellation tokens are linked to the **sessio 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) + +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 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. @@ -523,6 +531,7 @@ Stateless mode has the strongest backpressure story. Each handler's lifetime is |---|---|---|---| | **Stateless** | Yes (handler = request) | HTTP/2 streams + Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | | **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams | `MaxStreamsPerConnection` (default: 100) | +| **SSE (legacy)** | 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 | From 88bad8b67505d1d1b5954a23717dc522c1229448 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 18:56:11 -0700 Subject: [PATCH 32/42] Disable legacy SSE endpoints by default (MCP9003) Legacy SSE endpoints (/sse and /message) are now disabled by default because the SSE transport has no built-in HTTP-level backpressure -- POST returns 202 Accepted immediately without waiting for handler completion. This means default stateful and stateless modes now provide identical backpressure characteristics. To opt in, set HttpServerTransportOptions.EnableLegacySse = true (marked [Obsolete] with MCP9003) or use the AppContext switch ModelContextProtocol.AspNetCore.EnableLegacySse. SSE endpoints remain always disabled in stateless mode regardless of this setting. Update sessions.md, transports.md, and list-of-diagnostics.md to document the change, and migrate HttpTaskIntegrationTests to use Streamable HTTP since they were only incidentally using SSE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 29 +++++++------ docs/concepts/transports/transports.md | 18 +++++--- docs/list-of-diagnostics.md | 1 + src/Common/Obsoletions.cs | 4 ++ .../HttpServerTransportOptions.cs | 33 ++++++++++++++ .../McpEndpointRouteBuilderExtensions.cs | 43 +++++++++++++------ .../ModelContextProtocol.AspNetCore.csproj | 1 + tests/Directory.Build.props | 2 + .../HttpTaskIntegrationTests.cs | 6 +-- .../MapMcpSseTests.cs | 12 ++++-- .../MapMcpStreamableHttpTests.cs | 41 +++++++++++++++++- .../MapMcpTests.cs | 4 +- .../SseIntegrationTests.cs | 11 ++--- .../Program.cs | 2 +- 14 files changed, 161 insertions(+), 46 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 5a23fbe33..00c18f089 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -15,7 +15,7 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** - Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** -- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful.** (Legacy SSE endpoints are disabled in stateless mode.) +- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful** with (disabled by default due to [backpressure concerns](#sse-legacy-1)). - 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.** - Otherwise → **Use stateless** (`options.Stateless = true`). @@ -52,7 +52,7 @@ When - 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 - 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 the [legacy SSE endpoints](#sse-legacy) (`/sse` and `/message`) are disabled — clients that only support the legacy SSE transport cannot connect +- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#sse-legacy) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect - **Server-to-client requests are disabled**, including: - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) @@ -101,7 +101,7 @@ This means servers that need user confirmation, LLM reasoning, or other client i When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: -- Server-to-client requests (sampling, elicitation, roots) via an open SSE stream +- Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - Unsolicited notifications (resource updates, logging messages) - Resource subscriptions - Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) @@ -113,7 +113,7 @@ Use stateful mode when your server needs one or more of: - **Server-to-client requests**: Tools that call `ElicitAsync`, `SampleAsync`, or `RequestRootsAsync` to interact with the client - **Unsolicited notifications**: Sending resource-changed notifications or log messages without a preceding client request - **Resource subscriptions**: Clients subscribing to resource changes and receiving updates -- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) (the `/sse` and `/message` endpoints are only available in stateful mode) +- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) — requires (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. @@ -131,7 +131,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | | **Unsolicited notifications** | 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, but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | +| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may 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 | @@ -208,7 +208,10 @@ Stateful sessions introduce several challenges for production, internet-facing s ### SSE (legacy) -The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are only mapped when `Stateless = false` (the default), because the GET and POST requests must be handled by the same server process sharing in-memory session state. +The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** because the SSE transport has [no built-in HTTP-level backpressure](#sse-legacy-1). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. + +> [!NOTE] +> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. #### How SSE sessions work @@ -472,7 +475,7 @@ For task-augmented requests, the specification [requires](https://modelcontextpr ### 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. The key factor is whether the HTTP POST response stays open while the handler runs — because when it does, HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits how many concurrent handlers a single client connection can drive. +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) @@ -486,7 +489,9 @@ One difference from gRPC: handler cancellation tokens are linked to the **sessio 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) +#### SSE (legacy — opt-in only) + +Legacy SSE endpoints are [disabled by default](#sse-legacy) and must be explicitly enabled via . 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. @@ -523,15 +528,15 @@ For servers using the built-in automatic task handlers without external work dis #### Stateless mode -Stateless mode has the strongest backpressure story. Each handler's lifetime is the HTTP request's lifetime — `McpServer.DisposeAsync()` awaits all in-flight handlers before the POST response completes. This means Kestrel's connection limits, HTTP/2 `MaxStreamsPerConnection`, request timeouts, and rate-limiting middleware all apply naturally — identical to a standard ASP.NET Core minimal API or controller action. +Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above). #### Summary | Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | |---|---|---|---| -| **Stateless** | Yes (handler = request) | HTTP/2 streams + Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | -| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams | `MaxStreamsPerConnection` (default: 100) | -| **SSE (legacy)** | No (returns 202 Accepted) | None built-in; GET stream provides cleanup | Unbounded — apply rate limiting | +| **Stateless** | Yes (handler = request) | HTTP/2 streams, Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | +| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, Kestrel 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 | diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index b4abc7067..617aa18bf 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -113,7 +113,7 @@ await using var client = await McpClient.ResumeSessionAsync(transport, new Resum #### Streamable HTTP server (ASP.NET Core) -Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The method maps the Streamable HTTP endpoint at the specified route (root by default). It also maps legacy SSE endpoints at `{route}/sse` and `{route}/message` for backward compatibility. +Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The method maps the Streamable HTTP endpoint at the specified route (root by default). ```csharp var builder = WebApplication.CreateBuilder(args); @@ -141,7 +141,7 @@ A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] app.MapMcp("/mcp"); ``` -When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). +When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:sessions#sse-legacy)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). ### SSE transport (legacy) @@ -178,7 +178,7 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. The same `MapMcp()` endpoint handles both protocols — clients connecting with SSE are automatically served using the legacy SSE mechanism. SSE requires stateful mode (the default); legacy SSE endpoints are not mapped when `Stateless = true`. +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](xref:sessions#sse-legacy-1). To enable them, set to `true`. SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. ```csharp var builder = WebApplication.CreateBuilder(args); @@ -188,18 +188,24 @@ builder.Services.AddMcpServer() { // SSE requires stateful mode (the default). Set explicitly for forward compatibility. options.Stateless = false; + +#pragma warning disable MCP9003 // EnableLegacySse is obsolete + // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. + // See sessions doc for backpressure implications. + options.EnableLegacySse = true; +#pragma warning restore MCP9003 }) .WithTools(); var app = builder.Build(); -// MapMcp() serves both Streamable HTTP and legacy SSE. -// SSE clients connect to /sse (or {route}/sse for custom routes). +// MapMcp() serves Streamable HTTP. Legacy SSE (/sse and /message) is also +// available because EnableLegacySse is set to true above. app.MapMcp(); app.Run(); ``` -No additional configuration is needed. When a client connects using the SSE protocol, the server responds with an SSE stream for server-to-client messages and accepts client-to-server messages via a separate POST endpoint. +See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details on SSE session lifetime, configuration, and backpressure implications. ### Transport mode comparison diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 59666d518..dc117fecb 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -36,3 +36,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | :------------ | :----- | :---------- | | `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. | | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | +| `MCP9003` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details. | diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index bbc7bffb4..eea7064c1 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -25,4 +25,8 @@ internal static class Obsoletions // MCP9002 was used for the AddXxxFilter extension methods on IMcpServerBuilder that were superseded by // WithMessageFilters() and WithRequestFilters(). The APIs were removed; do not reuse this diagnostic ID. + + public const string EnableLegacySse_DiagnosticId = "MCP9003"; + public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead."; + public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 33d5c7c1e..6fcfc69be 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -61,6 +61,39 @@ public class HttpServerTransportOptions /// public bool Stateless { get; set; } + /// + /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) + /// for backward compatibility with clients that do not support the Streamable HTTP transport. + /// + /// + /// to map the legacy SSE endpoints; to disable them. The default is . + /// + /// + /// + /// The legacy SSE transport separates request and response channels: clients POST JSON-RPC messages + /// to /message and receive responses through a long-lived GET SSE stream on /sse. + /// Because the POST endpoint returns 202 Accepted immediately, there is no HTTP-level + /// backpressure on handler concurrency — unlike Streamable HTTP, where each POST is held open + /// until the handler responds. + /// + /// + /// Use Streamable HTTP instead whenever possible. If you must support legacy SSE clients, + /// enable this property only for completely trusted clients in isolated processes, and apply + /// HTTP rate-limiting middleware and reverse proxy limits to compensate for the lack of + /// built-in backpressure. + /// + /// + /// Setting this to while is also + /// throws an at startup, because SSE requires in-memory session state. + /// + /// + /// This property can also be enabled via the ModelContextProtocol.AspNetCore.EnableLegacySse + /// switch. + /// + /// + [Obsolete(Obsoletions.EnableLegacySse_Message, DiagnosticId = Obsoletions.EnableLegacySse_DiagnosticId, UrlFormat = Obsoletions.EnableLegacySse_Url)] + public bool EnableLegacySse { get; set; } + /// /// Gets or sets the event store for resumability support. /// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header. diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 80f3436e7..5f9240e96 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -13,6 +13,9 @@ namespace Microsoft.AspNetCore.Builder; /// public static class McpEndpointRouteBuilderExtensions { + private static bool EnableLegacySseSwitch { get; } = + AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.EnableLegacySse", out var enabled) && enabled; + /// /// Sets up endpoints for handling MCP Streamable HTTP transport. /// @@ -22,13 +25,24 @@ public static class McpEndpointRouteBuilderExtensions /// The required MCP services have not been registered. Ensure has been called during application startup. /// /// For details about the Streamable HTTP transport, see the 2025-11-25 protocol specification. - /// This method also maps legacy SSE endpoints for backward compatibility at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the 2024-11-05 protocol specification. + /// When legacy SSE is enabled via , this method also maps legacy SSE endpoints at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the 2024-11-05 protocol specification. /// public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { var streamableHttpHandler = endpoints.ServiceProvider.GetService() ?? throw new InvalidOperationException("You must call WithHttpTransport(). Unable to find required services. Call builder.Services.AddMcpServer().WithHttpTransport() in application startup code."); + var options = streamableHttpHandler.HttpServerTransportOptions; + +#pragma warning disable MCP9003 // EnableLegacySse - reading the obsolete property to check if SSE is enabled + if (options.Stateless && (options.EnableLegacySse || EnableLegacySseSwitch)) + { + throw new InvalidOperationException( + "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + + "shared between the GET /sse and POST /message requests. Remove the EnableLegacySse setting or disable stateless mode."); + } +#pragma warning restore MCP9003 + var mcpGroup = endpoints.MapGroup(pattern); var streamableHttpGroup = mcpGroup.MapGroup("") .WithDisplayName(b => $"MCP Streamable HTTP | {b.DisplayName}") @@ -39,7 +53,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - if (!streamableHttpHandler.HttpServerTransportOptions.Stateless) + if (!options.Stateless) { // The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages. // Resuming streams via GET is currently not supported in Stateless mode. @@ -49,17 +63,22 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo // The DELETE endpoint is not mapped in Stateless mode since there is no server-side state for the DELETE to clean up. streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync); - // Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot guarantee the /message requests - // will be handled by the same process as the /sse request. - var sseHandler = endpoints.ServiceProvider.GetRequiredService(); - var sseGroup = mcpGroup.MapGroup("") - .WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}"); +#pragma warning disable MCP9003 // EnableLegacySse - reading the obsolete property to check if SSE is enabled + if (options.EnableLegacySse || EnableLegacySseSwitch) +#pragma warning restore MCP9003 + { + // Map legacy HTTP with SSE endpoints. These are disabled by default because the SSE transport + // has no built-in request backpressure (POST returns 202 immediately). Enable only for trusted clients. + var sseHandler = endpoints.ServiceProvider.GetRequiredService(); + var sseGroup = mcpGroup.MapGroup("") + .WithDisplayName(b => $"MCP HTTP with SSE | {b.DisplayName}"); - sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync) - .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); - sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync) - .WithMetadata(new AcceptsMetadata(["application/json"])) - .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); + sseGroup.MapGet("/sse", sseHandler.HandleSseRequestAsync) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); + sseGroup.MapPost("/message", sseHandler.HandleMessageRequestAsync) + .WithMetadata(new AcceptsMetadata(["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); + } } return mcpGroup; diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index ee10fc15a..980cd1a40 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 2f0b76769..b83156b1a 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -5,5 +5,7 @@ True $(NoWarn);MCPEXP001 + + $(NoWarn);MCP9003 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs index b8b063708..2b74fcd14 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -11,14 +11,14 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// Integration tests for MCP Tasks feature over HTTP transports. -/// Tests task creation, polling, cancellation, and result retrieval across SSE streams. +/// Tests task creation, polling, cancellation, and result retrieval. /// public class HttpTaskIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper) { private readonly HttpClientTransportOptions DefaultTransportOptions = new() { - Endpoint = new("http://localhost:5000/sse"), - Name = "In-memory SSE Client", + Endpoint = new("http://localhost:5000/"), + Name = "In-memory Streamable HTTP Client", }; private Task ConnectMcpClientAsync( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index 46edd23f6..b796d78c2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -10,12 +10,18 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output protected override bool UseStreamableHttp => false; protected override bool Stateless => false; + protected override void ConfigureStateless(HttpServerTransportOptions options) + { + base.ConfigureStateless(options); + options.EnableLegacySse = true; + } + [Theory] [InlineData("/mcp")] [InlineData("/mcp/secondary")] public async Task Allows_Customizing_Route(string pattern) { - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); app.MapMcp(pattern); @@ -47,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat Name = "TestCustomRouteServer", Version = "1.0.0", }; - }).WithHttpTransport(); + }).WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); app.MapMcp(routePattern); @@ -77,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode() return "Complete"; }, options: new() { Name = "polling_tool" }); - Builder.Services.AddMcpServer().WithHttpTransport().WithTools([pollingTool]); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 53099ad27..647586fe5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -7,6 +7,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Collections.Concurrent; +using System.Net; using System.Threading; using System.Threading.Tasks; @@ -96,6 +97,42 @@ public async Task AutoDetectMode_Works_WithRootEndpoint() Assert.Equal("AutoDetectTestServer", mcpClient.ServerInfo.Name); } + [Fact] + public async Task SseEndpoints_AreDisabledByDefault_InStatefulMode() + { + Builder.Services.AddMcpServer().WithHttpTransport(options => + { + // Stateful mode, but SSE not explicitly enabled. + options.Stateless = false; + }); + await using var app = Builder.Build(); + + app.MapMcp(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + using var sseResponse = await HttpClient.GetAsync("/sse", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, sseResponse.StatusCode); + + using var messageResponse = await HttpClient.PostAsync("/message", new StringContent(""), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, messageResponse.StatusCode); + } + + [Fact] + public async Task SseEndpoints_ThrowOnMapMcp_InStatelessMode_WithEnableLegacySse() + { + Builder.Services.AddMcpServer().WithHttpTransport(options => + { + options.Stateless = true; + options.EnableLegacySse = true; + }); + await using var app = Builder.Build(); + + var ex = Assert.Throws(() => app.MapMcp()); + Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("EnableLegacySse", ex.Message); + } + [Fact] public async Task AutoDetectMode_Works_WithSseEndpoint() { @@ -108,7 +145,7 @@ public async Task AutoDetectMode_Works_WithSseEndpoint() Name = "AutoDetectSseTestServer", Version = "1.0.0", }; - }).WithHttpTransport(ConfigureStateless); + }).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; }); await using var app = Builder.Build(); app.MapMcp(); @@ -136,7 +173,7 @@ public async Task SseMode_Works_WithSseEndpoint() Name = "SseTestServer", Version = "1.0.0", }; - }).WithHttpTransport(ConfigureStateless); + }).WithHttpTransport(options => { ConfigureStateless(options); options.EnableLegacySse = true; }); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index bee17be7c..03f1e25aa 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -17,7 +17,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI protected abstract bool UseStreamableHttp { get; } protected abstract bool Stateless { get; } - protected void ConfigureStateless(HttpServerTransportOptions options) + protected virtual void ConfigureStateless(HttpServerTransportOptions options) { options.Stateless = Stateless; } @@ -205,7 +205,7 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() [Fact] public async Task Server_ShutsDownQuickly_WhenClientIsConnected() { - Builder.Services.AddMcpServer().WithHttpTransport().WithTools(); + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs index 5ed7a48ef..800a6ce96 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs @@ -31,7 +31,7 @@ private Task ConnectMcpClientAsync(HttpClient? httpClient = null, Htt [Fact] public async Task ConnectAndReceiveMessage_InMemoryServer() { - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -83,6 +83,7 @@ public async Task ConnectAndReceiveNotification_InMemoryServer() Builder.Services.AddMcpServer() .WithHttpTransport(httpTransportOptions => { + httpTransportOptions.EnableLegacySse = true; #pragma warning disable MCPEXP002 // RunSessionHandler is experimental httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => { @@ -127,7 +128,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() { firstOptionsCallbackCallCount++; }) - .WithHttpTransport() + .WithHttpTransport(options => options.EnableLegacySse = true) .WithTools(); Builder.Services.AddMcpServer(options => @@ -171,7 +172,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() { Builder.Services.AddMcpServer() - .WithHttpTransport(); + .WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); @@ -218,7 +219,7 @@ public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException() { Builder.Services.AddMcpServer() - .WithHttpTransport(); + .WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); @@ -310,7 +311,7 @@ private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints, b [Fact] public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails() { - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 1a27c0c15..b4e03bf0f 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -459,7 +459,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide } builder.Services.AddMcpServer(ConfigureOptions) - .WithHttpTransport(); + .WithHttpTransport(options => options.EnableLegacySse = true); var app = builder.Build(); From 62a11b92d5b155b05dfab80e4848d46af76d2de1 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 19:25:12 -0700 Subject: [PATCH 33/42] Use generic HTTP server terminology instead of Kestrel-specific 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> --- docs/concepts/sessions/sessions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 00c18f089..cee7fc0d5 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -482,7 +482,7 @@ How well the server is protected against a flood of concurrent requests depends 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 -- Kestrel's `MaxStreamsPerConnection` (default: **100**) limits concurrent handlers per connection +- 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. @@ -534,8 +534,8 @@ Stateless mode provides the same HTTP-level backpressure as default stateful mod | Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | |---|---|---|---| -| **Stateless** | Yes (handler = request) | HTTP/2 streams, Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | -| **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, Kestrel timeouts | `MaxStreamsPerConnection` (default: 100) | +| **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 | From 4062b230c6975142df7452b199659da380f33042 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 20:12:54 -0700 Subject: [PATCH 34/42] Add SSE backpressure remarks to doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../HttpServerTransportOptions.cs | 13 +++++++++++++ .../Server/SseResponseStreamTransport.cs | 8 ++++++++ 2 files changed, 21 insertions(+) diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 6fcfc69be..6fba80d8d 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -152,8 +152,14 @@ public class HttpServerTransportOptions /// The amount of time the server waits between any active requests before timing out an MCP session. The default is 2 hours. /// /// + /// /// This value is checked in the background every 5 seconds. A client trying to resume a session will receive a 404 status code /// and should restart their session. A client can keep their session open by keeping a GET request open. + /// + /// + /// Legacy SSE sessions (when is enabled) are not subject to this timeout — their lifetime is + /// tied to the open GET /sse request, and they are removed immediately when the client disconnects. + /// /// public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2); @@ -164,9 +170,16 @@ public class HttpServerTransportOptions /// The maximum number of idle sessions to track in memory. The default is 10,000 sessions. /// /// + /// /// Past this limit, the server logs a critical error and terminates the oldest idle sessions, even if they have not reached /// their , until the idle session count is below this limit. Sessions with any active HTTP request /// are not considered idle and don't count towards this limit. + /// + /// + /// Legacy SSE sessions (when is enabled) are never considered idle because their lifetime is + /// tied to the open GET /sse request. They are not subject to or this limit — they exist + /// exactly as long as the SSE connection is open. + /// /// public int MaxIdleSessionCount { get; set; } = 10_000; diff --git a/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs b/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs index 315e4819e..03ace6e89 100644 --- a/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs +++ b/src/ModelContextProtocol.Core/Server/SseResponseStreamTransport.cs @@ -18,6 +18,14 @@ namespace ModelContextProtocol.Server; /// This transport is used in scenarios where the server needs to push messages to the client in real-time, /// such as when streaming completion results or providing progress updates during long-running operations. /// +/// +/// Backpressure consideration: The SSE transport separates request and response channels — the client POSTs +/// messages to a separate endpoint while responses flow over the SSE stream. If the HTTP handler for incoming +/// messages returns immediately (e.g., 202 Accepted) after calling , +/// there is no HTTP-level backpressure on handler concurrency. The ASP.NET Core integration disables legacy SSE +/// endpoints by default for this reason. If you are using this type directly, consider holding the POST response +/// open until the handler completes, or applying rate-limiting at the HTTP layer. +/// /// /// The response stream to write MCP JSON-RPC messages as SSE events to. /// From b7e68dc2902561fb6317d8a0311b05a8665e61e2 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 26 Mar 2026 22:01:55 -0700 Subject: [PATCH 35/42] Improve session docs, client behavior, and observability guidance 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> --- docs/concepts/cancellation/cancellation.md | 3 + docs/concepts/progress/progress.md | 3 + docs/concepts/prompts/prompts.md | 2 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 3 + docs/concepts/sessions/sessions.md | 210 +++++++++++++++--- docs/concepts/toc.yml | 4 +- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 4 +- .../HttpServerTransportOptions.cs | 3 +- .../McpEndpointRouteBuilderExtensions.cs | 7 +- .../MapMcpStreamableHttpTests.cs | 120 ++++++++++ .../DiagnosticTests.cs | 11 + 13 files changed, 326 insertions(+), 48 deletions(-) diff --git a/docs/concepts/cancellation/cancellation.md b/docs/concepts/cancellation/cancellation.md index 50753d259..55346b509 100644 --- a/docs/concepts/cancellation/cancellation.md +++ b/docs/concepts/cancellation/cancellation.md @@ -12,6 +12,9 @@ MCP supports [cancellation] of in-flight requests. Either side can cancel a prev [cancellation]: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation [task cancellation]: https://learn.microsoft.com/dotnet/standard/parallel-programming/task-cancellation +> [!NOTE] +> The source and lifetime of the `CancellationToken` provided to server handlers depends on the transport and session mode. In [stateless mode](xref:sessions#stateless-mode-recommended), the token is tied to the HTTP request — if the client disconnects, the handler is cancelled. In [stateful mode](xref:sessions#stateful-mode-sessions), the token is tied to the session lifetime. See [Cancellation and disposal](xref:sessions#cancellation-and-disposal) for details. + ### How cancellation maps to MCP notifications When a `CancellationToken` passed to a client method (such as ) is cancelled, a `notifications/cancelled` notification is sent to the server with the request ID. On the server side, the `CancellationToken` provided to the tool method is then triggered, allowing the handler to stop work gracefully. This same mechanism works in reverse for server-to-client requests. diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md index e259a224e..e8e77c534 100644 --- a/docs/concepts/progress/progress.md +++ b/docs/concepts/progress/progress.md @@ -15,6 +15,9 @@ Typically progress tracking is supported by server tools that perform operations However, progress tracking is defined in the MCP specification as a general feature that can be implemented for any request that's handled by either a server or a client. This project illustrates the common case of a server tool that performs a long-running operation and sends progress updates to the client. +> [!NOTE] +> Progress notifications are sent inline as part of the response to a request — they are not unsolicited. Progress tracking works in both [stateless and stateful](xref:sessions) modes as well as stdio. + ### Server Implementation When processing a request, the server can use the extension method of to send progress updates, diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 4dba463a1..04c13f210 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -197,7 +197,7 @@ foreach (var message in result.Messages) ### Prompt list change notifications -Servers can dynamically add, remove, or modify prompts at runtime and notify connected clients. +Servers can dynamically add, remove, or modify prompts at runtime and notify connected clients. These are unsolicited notifications, so they require [stateful mode or stdio](xref:sessions) — [stateless](xref:sessions#stateless-mode-recommended) servers cannot send unsolicited notifications. #### Sending notifications from the server diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 94b330871..332cd1a5d 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -57,7 +57,7 @@ await using var client = await McpClient.CreateAsync(transport, options); ### Requesting roots from the server -Servers can request the client's root list using : +Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:sessions) — it is not available in [stateless mode](xref:sessions#stateless-mode-recommended). ```csharp [McpServerTool, Description("Lists the user's project roots")] diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 6ff7ec6fa..a93508e25 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -11,6 +11,9 @@ MCP [sampling] allows servers to request LLM completions from the client. This e [sampling]: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling +> [!NOTE] +> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:sessions). Sampling is not available in [stateless mode](xref:sessions#stateless-mode-recommended) because stateless servers cannot send requests to clients. + ### How sampling works 1. The server calls (or uses the adapter) during tool execution. diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index cee7fc0d5..cefa6bfe6 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -1,13 +1,13 @@ --- title: Sessions author: halter73 -description: How sessions work in the MCP C# SDK and when to use stateless vs. stateful mode for HTTP servers. +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: sessions --- # Sessions -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. Sessions enable features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, resource subscriptions, and session-scoped state. However, **most servers don't need sessions and should run in stateless mode** to avoid unnecessary complexity, memory overhead, and deployment constraints. +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. Sessions enable features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, resource subscriptions, and session-scoped state. Both the server and client participate in session management — the server creates and tracks sessions, while the client automatically includes the session ID in subsequent requests, detects session expiry, and can resume sessions across reconnections. **Most servers don't need sessions and should run in stateless mode** to avoid unnecessary complexity, memory overhead, and deployment constraints. [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -15,7 +15,7 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** - Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** -- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful** with (disabled by default due to [backpressure concerns](#sse-legacy-1)). +- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful** with (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.** - Otherwise → **Use stateless** (`options.Stateless = true`). @@ -45,7 +45,7 @@ app.MapMcp(); app.Run(); ``` -### What stateless mode disables +### What stateless mode changes When is `true`: @@ -57,7 +57,11 @@ When - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) - [Roots](xref:roots) (`RequestRootsAsync`) -- Unsolicited server-to-client notifications (e.g., resource update notifications, logging messages) are not supported + + The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. +- **Unsolicited server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client request. +- **No concurrent client isolation.** Every request is independent — the server cannot 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. @@ -78,15 +82,6 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, > [!TIP] > If you're unsure whether you need sessions, start with stateless mode. You can always switch to stateful mode later if you need server-to-client requests or other session features. -### What you give up with stateless mode - -Stateless mode trades features for simplicity: - -- **No server-to-client requests.** Sampling, elicitation, and roots all require the server to send a JSON-RPC request back to the client over a persistent connection. Stateless mode has no such connection. The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to solve this, but it is not yet available. -- **No push notifications.** The server cannot send unsolicited messages — log entries, resource-change events, or progress updates outside the scope of a tool call response. Every notification must be part of a direct response to a client request. -- **No concurrent client isolation.** Every request is independent. The server cannot 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.** When a client disconnects and reconnects (e.g., an editor restarting), stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start — because there was never a session to begin with. If your server holds any external state, you must manage cleanup through other means. - ### Stateless alternatives for server-to-client interactions @@ -208,7 +203,7 @@ Stateful sessions introduce several challenges for production, internet-facing s ### SSE (legacy) -The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** because the SSE transport has [no built-in HTTP-level backpressure](#sse-legacy-1). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. +The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** because the SSE transport has [no built-in HTTP-level backpressure](#request-backpressure). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. > [!NOTE] > Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. @@ -448,23 +443,123 @@ For stateless servers, shutdown is even simpler: each request is independent, so 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 are typically invisible at default log levels but may 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 +## Client-side session behavior + +The SDK's MCP client () 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 , 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 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 `TransportClosedException` with containing the HTTP status code +2. Cancels all in-flight operations +3. Completes the task + +**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. + +The following example demonstrates how to detect session expiry and reconnect: + +```csharp +async Task 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 example above 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 (, ). 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 : + +- — set via +- +- +- (optional) +- (optional) + +See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. + +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 property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. + +### Client transport options + +The following properties affect client-side session behavior: + +| Property | Default | Description | +|----------|---------|-------------| +| | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | +| | `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. | +| | `null` | Custom headers included in all requests (e.g., 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). + +## 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 configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. -#### Stateless mode +### 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` or `tasks/result` 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 +### Stateful mode In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. 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 backed by an external store. See [Fault-tolerant task implementations](xref:tasks#fault-tolerant-task-implementations) for guidance. -#### Task cancellation vs request cancellation +### Task cancellation vs request cancellation The MCP specification defines two distinct cancellation mechanisms: @@ -473,11 +568,11 @@ The MCP specification defines two distinct cancellation mechanisms: 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 +## 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) +### 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: @@ -489,7 +584,7 @@ One difference from gRPC: handler cancellation tokens are linked to the **sessio 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) +### SSE (legacy — opt-in only) Legacy SSE endpoints are [disabled by default](#sse-legacy) and must be explicitly enabled via . This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure. @@ -499,7 +594,7 @@ Internally, handlers are dispatched with the same fire-and-forget pattern as Str 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 +### With 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. @@ -512,7 +607,7 @@ When a handler calls `EnablePollingAsync()`: 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 (experimental) +### With tasks (experimental) [Tasks](xref:tasks) are an experimental feature that enables 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 , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. @@ -526,11 +621,11 @@ Tasks are a natural fit for **stateless deployments at scale**, where the `IMcpT 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 Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above). -#### Summary +### Summary | Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | |---|---|---|---| @@ -540,17 +635,64 @@ Stateless mode provides the same HTTP-level backpressure as default stateful mod | **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 +## 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 and 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. -The SDK automatically integrates with [.NET's OpenTelemetry support](https://learn.microsoft.com/dotnet/core/diagnostics/distributed-tracing) and attaches session metadata to traces and metrics. +- **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. -#### Activity tags +### Correlating with the transport session ID + +The transport session ID (, the `Mcp-Session-Id` header value) and the `mcp.session.id` activity tag are not automatically correlated by the SDK. If you need to correlate them — for example, to match a log entry to a specific trace — you can read the transport session ID from the `Mcp-Session-Id` header 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; -Every server-side request activity is tagged with `mcp.session.id` — the session's unique identifier. In stateless mode, this tag is `null` because there is no persistent session. Other tags include `mcp.method.name`, `mcp.protocol.version`, `jsonrpc.request.id`, and operation-specific tags like `gen_ai.tool.name` for tool calls. + // Read from request headers first. This is available on all non-initialize + // requests in stateful mode, because the client echoes back the session ID + // it received from the server's initialize response. + var sessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault(); -Use these tags to filter and correlate traces by session in your observability platform (Jaeger, Zipkin, Application Insights, etc.). + var result = await next(context); -#### Metrics + // After the handler runs, check response headers. In stateful mode, the server + // sets Mcp-Session-Id on every POST and GET response — not just initialize — + // so the session ID is always available here even for the first request. + // DELETE responses do not include the header, but the request header has it. + sessionId ??= httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); + + // sessionId is null only in stateless mode, where the server doesn't use sessions. + // In stateful mode, every successful response carries the header. + if (sessionId is not null) + { + logger.LogInformation("MCP transport session: {SessionId}", sessionId); + } + + return result; +}); +``` + + +> [!NOTE] +> In stateful mode, the `Mcp-Session-Id` response header is set on **every POST and GET response**, not just the `initialize` response. This means the session ID is always available in the filter after `await next(context)`. The only case where `sessionId` is `null` is in stateless mode, where the server doesn't use sessions at all. + + +> [!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: @@ -563,10 +705,6 @@ The SDK records histograms under the `Experimental.ModelContextProtocol` meter: 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. -#### Distributed tracing - -The SDK propagates [W3C trace context](https://www.w3.org/TR/trace-context/) (`traceparent` / `tracestate`) through JSON-RPC messages via the `_meta` field. This means a client's tool call and the server's handling of that call appear as parent-child spans in a distributed trace, regardless of transport. - ## Advanced features ### Session migration diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 0846dc68a..30fd8dcd0 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -9,6 +9,8 @@ items: uid: capabilities - name: Transports uid: transports + - name: Sessions + uid: sessions - name: Ping uid: ping - name: Progress @@ -39,8 +41,6 @@ items: uid: completions - name: Logging uid: logging - - name: Sessions - uid: sessions - name: HTTP Context uid: httpcontext - name: Filters diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 1fb62d651..c3928d8a2 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -262,7 +262,7 @@ if (result.IsError is true) ### Tool list change notifications -Servers can dynamically add, remove, or modify tools at runtime. When the tool list changes, the server notifies connected clients so they can refresh their tool list. +Servers can dynamically add, remove, or modify tools at runtime. When the tool list changes, the server notifies connected clients so they can refresh their tool list. These are unsolicited notifications, so they require [stateful mode or stdio](xref:sessions) — [stateless](xref:sessions#stateless-mode-recommended) servers cannot send unsolicited notifications. #### Sending notifications from the server diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 617aa18bf..c6d385222 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -178,7 +178,7 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](xref:sessions#sse-legacy-1). To enable them, set to `true`. SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](xref:sessions#request-backpressure). To enable them, set to `true`. SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. ```csharp var builder = WebApplication.CreateBuilder(args); @@ -255,3 +255,5 @@ var tools = await client.ListToolsAsync(); var echo = tools.First(t => t.Name == "echo"); Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` + +Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:sessions) for how session behavior varies across transports. diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 6fba80d8d..648cb86df 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -92,7 +92,8 @@ public class HttpServerTransportOptions /// /// [Obsolete(Obsoletions.EnableLegacySse_Message, DiagnosticId = Obsoletions.EnableLegacySse_DiagnosticId, UrlFormat = Obsoletions.EnableLegacySse_Url)] - public bool EnableLegacySse { get; set; } + public bool EnableLegacySse { get; set; } = + AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.EnableLegacySse", out var enabled) && enabled; /// /// Gets or sets the event store for resumability support. diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index 5f9240e96..6128c6d30 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -13,9 +13,6 @@ namespace Microsoft.AspNetCore.Builder; /// public static class McpEndpointRouteBuilderExtensions { - private static bool EnableLegacySseSwitch { get; } = - AppContext.TryGetSwitch("ModelContextProtocol.AspNetCore.EnableLegacySse", out var enabled) && enabled; - /// /// Sets up endpoints for handling MCP Streamable HTTP transport. /// @@ -35,7 +32,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo var options = streamableHttpHandler.HttpServerTransportOptions; #pragma warning disable MCP9003 // EnableLegacySse - reading the obsolete property to check if SSE is enabled - if (options.Stateless && (options.EnableLegacySse || EnableLegacySseSwitch)) + if (options.Stateless && options.EnableLegacySse) { throw new InvalidOperationException( "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + @@ -64,7 +61,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync); #pragma warning disable MCP9003 // EnableLegacySse - reading the obsolete property to check if SSE is enabled - if (options.EnableLegacySse || EnableLegacySseSwitch) + if (options.EnableLegacySse) #pragma warning restore MCP9003 { // Map legacy HTTP with SSE endpoints. These are disabled by default because the SSE transport diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 647586fe5..365d4b083 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -588,4 +588,124 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithUnsolicite // Dispose should still not hang await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); } + + [Fact] + public async Task Client_CanReconnect_AfterSessionExpiry() + { + Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode."); + + string? expiredSessionId = null; + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); + + await using var app = Builder.Build(); + + // Middleware that returns 404 for the expired session, simulating server-side session expiry. + app.Use(next => + { + return async context => + { + if (expiredSessionId is not null && + context.Request.Headers["Mcp-Session-Id"].ToString() == expiredSessionId) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + await next(context); + }; + }); + + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Connect the first client and verify it works. + var client1 = await ConnectAsync(); + var originalSessionId = client1.SessionId; + Assert.NotNull(originalSessionId); + + var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotEmpty(tools); + + // Simulate session expiry by having the middleware reject the original session. + expiredSessionId = originalSessionId; + + // The next request should fail. + await Assert.ThrowsAnyAsync(async () => + await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + + // Completion should resolve with a 404 status code. + var details = await client1.Completion.WaitAsync(TestContext.Current.CancellationToken); + var httpDetails = Assert.IsType(details); + Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode); + + await client1.DisposeAsync(); + + // Reconnect with a brand-new session. + await using var client2 = await ConnectAsync(); + Assert.NotNull(client2.SessionId); + Assert.NotEqual(originalSessionId, client2.SessionId); + + // The new session works normally. + tools = await client2.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotEmpty(tools); + } + + [Fact] + public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() + { + var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>(); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); + + await using var app = Builder.Build(); + + // This is the pattern documented in sessions.md — verify it actually works. + app.MapMcp().AddEndpointFilter(async (context, next) => + { + var httpContext = context.HttpContext; + + // Read from request headers — available on all non-initialize requests in stateful mode. + var beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault(); + + var result = await next(context); + + // After the handler, check response headers. + var afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); + + capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); + return result; + }); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // The filter ran for at least the initialize, initialized notification, and list_tools POSTs. + Assert.True(capturedSessionIds.Count >= 3); + + if (Stateless) + { + // Stateless mode: no session IDs anywhere. + Assert.All(capturedSessionIds, c => + { + Assert.Null(c.BeforeNext); + Assert.Null(c.AfterNext); + }); + } + else + { + // Stateful mode: response header is set on every POST and GET response. + var postAndGetCaptures = capturedSessionIds.Where(c => c.Method is "POST" or "GET"); + Assert.All(postAndGetCaptures, c => + { + Assert.Equal(client.SessionId, c.AfterNext); + }); + + // At least one POST should have the session ID in the request header too + // (the initialized notification or list_tools — but not the initial initialize request). + Assert.Contains(capturedSessionIds, c => c.BeforeNext == client.SessionId); + } + } } diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index 2ae0fea38..bbe7d153f 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -87,6 +87,17 @@ await WaitForAsync(() => activities.Any(a => using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\""))); var metaJson = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetRawText(); Assert.Equal($$"""{"traceparent":"00-{{clientListToolsCall.TraceId}}-{{clientListToolsCall.SpanId}}-01"}""", metaJson); + + // Validate that mcp.session.id is set on both client and server activities and that + // all client activities share one session ID while all server activities share another. + var clientSessionId = Assert.Single(clientToolCall.Tags, t => t.Key == "mcp.session.id").Value; + var serverSessionId = Assert.Single(serverToolCall.Tags, t => t.Key == "mcp.session.id").Value; + Assert.NotNull(clientSessionId); + Assert.NotNull(serverSessionId); + Assert.NotEqual(clientSessionId, serverSessionId); + + Assert.Equal(clientSessionId, clientListToolsCall.Tags.Single(t => t.Key == "mcp.session.id").Value); + Assert.Equal(serverSessionId, serverListToolsCall.Tags.Single(t => t.Key == "mcp.session.id").Value); } [Fact] From 3f71fced4c3f9fa8e0bb1f27d374e1da7abaeaf9 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 01:49:02 -0700 Subject: [PATCH 36/42] Clarify session intro: recommend stateless, spec-required client behavior 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> --- docs/concepts/sessions/sessions.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index cefa6bfe6..ca6bba267 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -7,7 +7,9 @@ uid: sessions # Sessions -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. Sessions enable features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, resource subscriptions, and session-scoped state. Both the server and client participate in session management — the server creates and tracks sessions, while the client automatically includes the session ID in subsequent requests, detects session expiry, and can resume sessions across reconnections. **Most servers don't need sessions and should run in stateless mode** to avoid unnecessary complexity, memory overhead, and deployment constraints. +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 to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push unsolicited notifications, or maintain per-client state across requests. + +When sessions are enabled (the current C# SDK default), 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 @@ -22,7 +24,7 @@ The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to asso > [!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](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. +> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. ## Stateless mode (recommended) From 4f885d22527e4ed268ac1477fd2e055dc7a7cbf0 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 02:15:43 -0700 Subject: [PATCH 37/42] Reorganize sessions.md and use endpoint filter with Activity tag 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> --- docs/concepts/sessions/sessions.md | 263 +++++++++--------- docs/concepts/transports/transports.md | 4 +- docs/list-of-diagnostics.md | 2 +- .../MapMcpStreamableHttpTests.cs | 36 ++- 4 files changed, 166 insertions(+), 139 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index ca6bba267..f5ff8832c 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -17,7 +17,7 @@ When sessions are enabled (the current C# SDK default), the server creates and t - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** - Does your server send unsolicited notifications or support resource subscriptions? → **Use stateful.** -- Do you need to support clients that only speak the [legacy SSE transport](#sse-legacy)? → **Use stateful** with (disabled by default due to [backpressure concerns](#request-backpressure)). +- Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with (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.** - Otherwise → **Use stateless** (`options.Stateless = true`). @@ -54,7 +54,7 @@ When - 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 - 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](#sse-legacy) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect +- 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 cannot connect - **Server-to-client requests are disabled**, including: - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) @@ -110,7 +110,7 @@ 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**: Sending resource-changed notifications or log messages without a preceding client request - **Resource subscriptions**: Clients subscribing to resource changes and receiving updates -- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#sse-legacy) — requires (disabled by default) +- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#legacy-sse-transport) — requires (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. @@ -203,39 +203,113 @@ Stateful sessions introduce several challenges for production, internet-facing s **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 and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. -### SSE (legacy) +### stdio 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** because the SSE transport has [no built-in HTTP-level backpressure](#request-backpressure). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. +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. -> [!NOTE] -> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. +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. -#### How SSE sessions work +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). -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 +## Client-side session behavior -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. +The SDK's MCP client () 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 lifetime +### Session lifecycle -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. +#### Joining a session -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. +When you call , the client: -#### Configuration +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) - and 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`. +This is entirely automatic — you don't need to manage the session ID yourself. The property exposes the current session ID (or `null` for transports that don't support sessions, like stdio). -### stdio transport +#### Session expiry -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. +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: -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. +1. Wraps the failure in a `TransportClosedException` with containing the HTTP status code +2. Cancels all in-flight operations +3. Completes the task -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). +**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. + +The following example demonstrates how to detect session expiry and reconnect: + +```csharp +async Task 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 example above 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 (, ). 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 : + +- — set via +- +- +- (optional) +- (optional) + +See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. + +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 property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. + +### Client transport options + +The following properties affect client-side session behavior: + +| Property | Default | Description | +|----------|---------|-------------| +| | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | +| | `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. | +| | `null` | Custom headers included in all requests (e.g., 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 @@ -326,13 +400,13 @@ builder.Services.AddMcpServer() .WithTools(); ``` -## Security +### Security and user binding -### 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 +##### 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: @@ -416,7 +490,7 @@ Stateless mode has the simplest cancellation story: the handler's `CancellationT ### 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`. The handler receives an `OperationCanceledException` like any other 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 may 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 @@ -445,106 +519,6 @@ For stateless servers, shutdown is even simpler: each request is independent, so 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 are typically invisible at default log levels but may 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. -## Client-side session behavior - -The SDK's MCP client () 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 , 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 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 `TransportClosedException` with containing the HTTP status code -2. Cancels all in-flight operations -3. Completes the task - -**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. - -The following example demonstrates how to detect session expiry and reconnect: - -```csharp -async Task 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 example above 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 (, ). 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 : - -- — set via -- -- -- (optional) -- (optional) - -See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. - -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 property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. - -### Client transport options - -The following properties affect client-side session behavior: - -| Property | Default | Description | -|----------|---------|-------------| -| | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | -| | `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. | -| | `null` | Custom headers included in all requests (e.g., 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). - ## 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 configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. @@ -588,7 +562,7 @@ For comparison, ASP.NET Core SignalR limits concurrent hub invocations per clien ### SSE (legacy — opt-in only) -Legacy SSE endpoints are [disabled by default](#sse-legacy) and must be explicitly enabled via . This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure. +Legacy SSE endpoints are [disabled by default](#legacy-sse-transport) and must be explicitly enabled via . 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. @@ -651,7 +625,7 @@ Every request `Activity` is tagged with `mcp.session.id` — a unique identifier ### Correlating with the transport session ID -The transport session ID (, the `Mcp-Session-Id` header value) and the `mcp.session.id` activity tag are not automatically correlated by the SDK. If you need to correlate them — for example, to match a log entry to a specific trace — you can read the transport session ID from the `Mcp-Session-Id` header using an [endpoint filter](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/min-api-filters) on `MapMcp()`: +The transport session ID (, 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) => @@ -671,11 +645,12 @@ app.MapMcp().AddEndpointFilter(async (context, next) => // DELETE responses do not include the header, but the request header has it. sessionId ??= httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); - // sessionId is null only in stateless mode, where the server doesn't use sessions. - // In stateful mode, every successful response carries the header. + // Tag the HTTP request Activity with the transport session ID so it appears + // alongside child MCP spans (which carry mcp.session.id) in your traces. + // sessionId is null only in stateless mode, where sessions don't exist. if (sessionId is not null) { - logger.LogInformation("MCP transport session: {SessionId}", sessionId); + Activity.Current?.AddTag("mcp.transport.session.id", sessionId); } return result; @@ -707,6 +682,32 @@ The SDK records histograms under the `Experimental.ModelContextProtocol` meter: 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 to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9003`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. + +> [!NOTE] +> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. + +### 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 + + and 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 diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index c6d385222..5325ad7a9 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -141,7 +141,7 @@ A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] app.MapMcp("/mcp"); ``` -When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:sessions#sse-legacy)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). +When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:sessions#legacy-sse-transport)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). ### SSE transport (legacy) @@ -205,7 +205,7 @@ app.MapMcp(); app.Run(); ``` -See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details on SSE session lifetime, configuration, and backpressure implications. +See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details on SSE session lifetime, configuration, and backpressure implications. ### Transport mode comparison diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index dc117fecb..d81afa418 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -36,4 +36,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | :------------ | :----- | :---------- | | `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. | | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | -| `MCP9003` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — SSE (legacy)](xref:sessions#sse-legacy) for details. | +| `MCP9003` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details. | diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 365d4b083..df4e4f8d2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -654,6 +654,7 @@ await Assert.ThrowsAnyAsync(async () => public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() { var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>(); + var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>(); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -671,8 +672,19 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() // After the handler, check response headers. var afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); + var sessionId = beforeSessionId ?? afterSessionId; capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); + + // Verify Activity.Current is available and AddTag works (the documented pattern). + var activity = System.Diagnostics.Activity.Current; + if (sessionId is not null) + { + activity?.AddTag("mcp.transport.session.id", sessionId); + } + var tagValue = activity?.GetTagItem("mcp.transport.session.id")?.ToString(); + capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method)); + return result; }); @@ -682,8 +694,9 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // The filter ran for at least the initialize, initialized notification, and list_tools POSTs. - Assert.True(capturedSessionIds.Count >= 3); + // The filter must have observed at least one MCP request. Don't assert an exact + // minimum — the initialized notification or GET stream may not have completed yet. + Assert.NotEmpty(capturedSessionIds); if (Stateless) { @@ -693,19 +706,32 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() Assert.Null(c.BeforeNext); Assert.Null(c.AfterNext); }); + + // Activity should exist but no transport session tag in stateless mode. + Assert.All(capturedActivityTags, c => Assert.Null(c.TagValue)); } else { // Stateful mode: response header is set on every POST and GET response. - var postAndGetCaptures = capturedSessionIds.Where(c => c.Method is "POST" or "GET"); - Assert.All(postAndGetCaptures, c => + var postCaptures = capturedSessionIds.Where(c => c.Method is "POST").ToList(); + Assert.NotEmpty(postCaptures); + + Assert.All(postCaptures, c => { Assert.Equal(client.SessionId, c.AfterNext); }); // At least one POST should have the session ID in the request header too // (the initialized notification or list_tools — but not the initial initialize request). - Assert.Contains(capturedSessionIds, c => c.BeforeNext == client.SessionId); + Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId); + + // Verify Activity.Current was available and the AddTag pattern works. + var postActivityTags = capturedActivityTags.Where(c => c.Method is "POST").ToList(); + Assert.All(postActivityTags, c => + { + Assert.True(c.HadActivity, "Activity.Current should be non-null in the endpoint filter"); + Assert.Equal(client.SessionId, c.TagValue); + }); } } } From 2271620e497da7c7c927c17ab8fcf5c852381b72 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 07:54:44 -0700 Subject: [PATCH 38/42] Add forward compat guidance, HTTP message delivery docs, and enhance transports.md Add 'Forward and backward compatibility' section to sessions.md explaining why servers should set Stateless explicitly. Add 'How Streamable HTTP delivers messages' section defining solicited (POST response streams) vs unsolicited (GET stream) message delivery. Enhance transports.md with message flow overview, SSE backpressure explanation, and backpressure row in the transport comparison table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 50 +++++++++++++++++++------- docs/concepts/transports/transports.md | 17 +++++++-- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index f5ff8832c..9d8e3a7f6 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -7,7 +7,7 @@ uid: sessions # Sessions -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 to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push unsolicited notifications, or maintain per-client state across requests. +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 to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. When sessions are enabled (the current C# SDK default), 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)). @@ -16,7 +16,7 @@ When sessions are enabled (the current C# SDK default), the server creates and t **Quick guide — which mode should I use?** - Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** -- Does your server send unsolicited notifications or support resource subscriptions? → **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 (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.** @@ -24,7 +24,19 @@ When sessions are enabled (the current C# SDK default), the server creates and t > [!NOTE] -> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features. If your server _does_ depend on stateful behavior, consider setting `Stateless = false` explicitly so your code is resilient to a potential future default change once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or similar mechanisms bring server-to-client interactions to stateless mode. +> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features — see [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing an explicit setting. + +## Forward and backward compatibility + +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The current C# SDK default is `Stateless = false` (sessions enabled), but **we expect this default to change** once mechanisms like [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) bring server-to-client interactions (sampling, elicitation, roots) to stateless mode. We recommend every server set `Stateless` explicitly rather than relying on the default: + +- **`Stateless = true`** — the best forward-compatible choice. Your server opts out of sessions entirely. No matter how the SDK default changes in the future, your behavior stays the same. 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. + +- **`Stateless = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. 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 will always honor your server's session. Once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or a similar mechanism is available, you may be able to migrate server-to-client interactions to stateless mode and drop sessions entirely — but until then, explicit `Stateless = false` is the safe choice. See [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions) for more on MRTR. + + +> [!TIP] +> If you're not sure which to pick, start with `Stateless = true`. You can switch to `Stateless = false` later if you discover you need server-to-client requests or unsolicited notifications. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. ## Stateless mode (recommended) @@ -61,7 +73,7 @@ When - [Roots](xref:roots) (`RequestRootsAsync`) The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. -- **Unsolicited server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client request. +- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. - **No concurrent client isolation.** Every request is independent — the server cannot 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. @@ -78,11 +90,7 @@ Use stateless mode when your server: - 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. - - -> [!TIP] -> If you're unsure whether you need sessions, start with stateless mode. You can always switch to stateful mode later if you need server-to-client requests or other session features. +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. See [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing between stateless and stateful mode. ### Stateless alternatives for server-to-client interactions @@ -99,7 +107,7 @@ This means servers that need user confirmation, LLM reasoning, or other client i When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream -- Unsolicited notifications (resource updates, logging messages) +- [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the GET stream - Resource subscriptions - Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) @@ -108,7 +116,7 @@ When 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**: Sending resource-changed notifications or log messages without a preceding client request +- **[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 (disabled by default) - **Session-scoped state**: Logic that must persist across multiple requests within the same session @@ -126,7 +134,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **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** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | -| **Unsolicited notifications** | Not supported | Supported (resource updates, logging) | +| **[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 (disabled by default), but some Streamable HTTP clients [may 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 | @@ -138,6 +146,24 @@ The [deployment considerations](#deployment-considerations) below are real conce ### 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: diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 5325ad7a9..2430c5bac 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -131,7 +131,13 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:sessions#cancellation-and-disposal) behavior during shutdown. +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:sessions#forward-and-backward-compatibility). See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:sessions#cancellation-and-disposal) behavior during shutdown. + +#### How messages flow + +In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes. + +In stateful mode, the client can also open a long-lived GET request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (e.g., resource-changed notifications from a background watcher). In stateless mode, the GET endpoint is not mapped, so every message must be part of a POST response. See [How Streamable HTTP delivers messages](xref:sessions#how-streamable-http-delivers-messages) for a detailed breakdown. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: @@ -178,7 +184,11 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](xref:sessions#request-backpressure). To enable them, set to `true`. SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9003`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. + +**Why SSE is disabled by default.** The SSE transport separates request and response channels: clients POST JSON-RPC messages to `/message` and receive all responses through a long-lived GET SSE stream on `/sse`. Because the POST endpoint returns `202 Accepted` immediately — before the handler even runs — there is **no HTTP-level backpressure** on handler concurrency. A client (or attacker) can flood the server with tool calls without waiting for prior requests to complete. In contrast, Streamable HTTP holds each POST response open until the handler finishes, providing natural backpressure. See [Request backpressure](xref:sessions#request-backpressure) for a detailed comparison and mitigations if you must use SSE. + +To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp var builder = WebApplication.CreateBuilder(args); @@ -205,7 +215,7 @@ app.MapMcp(); app.Run(); ``` -See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details on SSE session lifetime, configuration, and backpressure implications. +See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details on SSE session lifetime and configuration. ### Transport mode comparison @@ -216,6 +226,7 @@ See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for | Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | Session ID via query string, tracked in memory | | Server-to-client requests | ✓ | ✗ (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458)) | ✓ | ✓ | | Unsolicited notifications | ✓ | ✗ | ✓ | ✓ | +| Backpressure | Implicit (stdin/stdout flow control) | ✓ (POST held open until handler completes) | ✓ (POST held open until handler completes) | ✗ (POST returns 202 immediately — see [backpressure](xref:sessions#request-backpressure)) | | Session resumption | N/A | N/A | ✓ | ✗ | | Horizontal scaling | N/A | No constraints | Requires session affinity | Requires session affinity | | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | From 2883c04b7d388c564ce021e12fea11d9a88c928d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 08:10:46 -0700 Subject: [PATCH 39/42] Mention ping in stateless restrictions list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 9d8e3a7f6..c03814d03 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -71,6 +71,7 @@ When - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) - [Roots](xref:roots) (`RequestRootsAsync`) + - Ping — the server cannot ping the client to verify connectivity The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. - **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. From d4289017b3bd9a6bad2de272cab9413cae13be0a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 08:19:57 -0700 Subject: [PATCH 40/42] Simplify endpoint filter: tag before next() for child span inheritance Move Activity.AddTag before next() so child spans created during request processing inherit the transport session ID. Accept that the first initialize request won't have the tag (no request header yet). Update test to match the simplified pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 27 +++++----------- .../MapMcpStreamableHttpTests.cs | 32 ++++++++++--------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index c03814d03..5730ffee5 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -659,34 +659,23 @@ app.MapMcp().AddEndpointFilter(async (context, next) => { var httpContext = context.HttpContext; - // Read from request headers first. This is available on all non-initialize - // requests in stateful mode, because the client echoes back the session ID - // it received from the server's initialize response. - var sessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault(); - - var result = await next(context); - - // After the handler runs, check response headers. In stateful mode, the server - // sets Mcp-Session-Id on every POST and GET response — not just initialize — - // so the session ID is always available here even for the first request. - // DELETE responses do not include the header, but the request header has it. - sessionId ??= httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); - - // Tag the HTTP request Activity with the transport session ID so it appears - // alongside child MCP spans (which carry mcp.session.id) in your traces. - // sessionId is null only in stateless mode, where sessions don't exist. - if (sessionId is not null) + // 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 result; + return await next(context); }); ``` > [!NOTE] -> In stateful mode, the `Mcp-Session-Id` response header is set on **every POST and GET response**, not just the `initialize` response. This means the session ID is always available in the filter after `await next(context)`. The only case where `sessionId` is `null` is in stateless mode, where the server doesn't use sessions at all. +> 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. > [!NOTE] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index df4e4f8d2..e8df75201 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -661,28 +661,28 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() await using var app = Builder.Build(); // This is the pattern documented in sessions.md — verify it actually works. + // Tag before next() so child spans inherit the value. app.MapMcp().AddEndpointFilter(async (context, next) => { var httpContext = context.HttpContext; // Read from request headers — available on all non-initialize requests in stateful mode. - var beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"].FirstOrDefault(); + string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"]; - var result = await next(context); - - // After the handler, check response headers. - var afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"].FirstOrDefault(); - var sessionId = beforeSessionId ?? afterSessionId; - - capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); - - // Verify Activity.Current is available and AddTag works (the documented pattern). + // Tag before next() so child activities created during the handler inherit it. var activity = System.Diagnostics.Activity.Current; - if (sessionId is not null) + if (beforeSessionId != null) { - activity?.AddTag("mcp.transport.session.id", sessionId); + activity?.AddTag("mcp.transport.session.id", beforeSessionId); } var tagValue = activity?.GetTagItem("mcp.transport.session.id")?.ToString(); + + var result = await next(context); + + // After the handler, check response headers too (for test validation only). + string? afterSessionId = httpContext.Response.Headers["Mcp-Session-Id"]; + + capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method)); return result; @@ -725,9 +725,11 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() // (the initialized notification or list_tools — but not the initial initialize request). Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId); - // Verify Activity.Current was available and the AddTag pattern works. - var postActivityTags = capturedActivityTags.Where(c => c.Method is "POST").ToList(); - Assert.All(postActivityTags, c => + // Verify Activity.Current was available and the AddTag pattern works before next(). + // The tag is only set on non-initialize requests (where the request header has the session ID). + var taggedPosts = capturedActivityTags.Where(c => c.Method is "POST" && c.TagValue is not null).ToList(); + Assert.NotEmpty(taggedPosts); + Assert.All(taggedPosts, c => { Assert.True(c.HadActivity, "Activity.Current should be non-null in the endpoint filter"); Assert.Equal(client.SessionId, c.TagValue); From fd022180021ca2e159d91917cb3c14298dc46e6c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 08:36:19 -0700 Subject: [PATCH 41/42] Add SSE migration guidance to forward compatibility section Explain that /sse endpoint clients are using the legacy transport which is now disabled by default. Cover client-side migration (change endpoint URL), server-side migration (EnableLegacySse opt-in), and transition period (both transports served simultaneously by MapMcp). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/sessions/sessions.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/sessions/sessions.md index 5730ffee5..3187cdc6d 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/sessions/sessions.md @@ -38,6 +38,26 @@ The `Stateless` property is the single most important setting for forward-proofi > [!TIP] > If you're not sure which to pick, start with `Stateless = true`. You can switch to `Stateless = false` later if you discover you need server-to-client requests or unsolicited notifications. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +### Migrating from legacy SSE + +If your clients connect to a `/sse` endpoint (e.g., `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** ( is `false` and marked `[Obsolete]` with diagnostic `MCP9003`). 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 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 `MCP9003` 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 `Stateless = false`. 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 `Stateless = true`. + ## 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. From f76b456297e0d9cd4e0b14f04aeb3c1aa99ea449 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 27 Mar 2026 10:58:13 -0700 Subject: [PATCH 42/42] Rename sessions doc to stateless Rename docs/concepts/sessions/ to docs/concepts/stateless/ and update the uid, title, toc.yml entry, all xref links, and error message URLs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/cancellation/cancellation.md | 2 +- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/httpcontext/httpcontext.md | 2 +- docs/concepts/index.md | 2 +- docs/concepts/logging/logging.md | 2 +- docs/concepts/progress/progress.md | 2 +- docs/concepts/prompts/prompts.md | 2 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- .../sessions.md => stateless/stateless.md} | 6 +++--- docs/concepts/toc.yml | 4 ++-- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 16 ++++++++-------- docs/list-of-diagnostics.md | 2 +- .../StreamableHttpHandler.cs | 4 ++-- 15 files changed, 26 insertions(+), 26 deletions(-) rename docs/concepts/{sessions/sessions.md => stateless/stateless.md} (99%) diff --git a/docs/concepts/cancellation/cancellation.md b/docs/concepts/cancellation/cancellation.md index 55346b509..618b2f08f 100644 --- a/docs/concepts/cancellation/cancellation.md +++ b/docs/concepts/cancellation/cancellation.md @@ -13,7 +13,7 @@ MCP supports [cancellation] of in-flight requests. Either side can cancel a prev [task cancellation]: https://learn.microsoft.com/dotnet/standard/parallel-programming/task-cancellation > [!NOTE] -> The source and lifetime of the `CancellationToken` provided to server handlers depends on the transport and session mode. In [stateless mode](xref:sessions#stateless-mode-recommended), the token is tied to the HTTP request — if the client disconnects, the handler is cancelled. In [stateful mode](xref:sessions#stateful-mode-sessions), the token is tied to the session lifetime. See [Cancellation and disposal](xref:sessions#cancellation-and-disposal) for details. +> The source and lifetime of the `CancellationToken` provided to server handlers depends on the transport and session mode. In [stateless mode](xref:stateless#stateless-mode-recommended), the token is tied to the HTTP request — if the client disconnects, the handler is cancelled. In [stateful mode](xref:stateless#stateful-mode-sessions), the token is tied to the session lifetime. See [Cancellation and disposal](xref:stateless#cancellation-and-disposal) for details. ### How cancellation maps to MCP notifications diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index ce89397db..94597fa5f 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici ### URL Elicitation Required Error -When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:sessions) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. +When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:stateless) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. #### Throwing UrlElicitationRequiredException on the Server diff --git a/docs/concepts/httpcontext/httpcontext.md b/docs/concepts/httpcontext/httpcontext.md index 915377565..34ee4dbda 100644 --- a/docs/concepts/httpcontext/httpcontext.md +++ b/docs/concepts/httpcontext/httpcontext.md @@ -37,7 +37,7 @@ When using the legacy SSE transport, be aware that the `HttpContext` returned by - The `HttpContext.User` may contain stale claims if the client's token was refreshed after the SSE connection was established. - Request headers, query strings, and other per-request metadata will reflect the initial SSE connection, not the current operation. -The Streamable HTTP transport does not have this issue because each tool call is its own HTTP request, so `IHttpContextAccessor.HttpContext` always reflects the current request. In [stateless](xref:sessions) mode, this is guaranteed since every request creates a fresh server context. +The Streamable HTTP transport does not have this issue because each tool call is its own HTTP request, so `IHttpContextAccessor.HttpContext` always reflects the current request. In [stateless](xref:stateless) mode, this is guaranteed since every request creates a fresh server context. > [!NOTE] diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 06ccec5fb..d7cde44e9 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -36,6 +36,6 @@ Install the SDK and build your first MCP client and server. | [Prompts](prompts/prompts.md) | Learn how to implement and consume reusable prompt templates with rich content types. | | [Completions](completions/completions.md) | Learn how to implement argument auto-completion for prompts and resource templates. | | [Logging](logging/logging.md) | Learn how to implement logging in MCP servers and how clients can consume log messages. | -| [Sessions](sessions/sessions.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | +| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. | | [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. | diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index 90f2fc0d9..aa78edab7 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -46,7 +46,7 @@ MCP servers that implement the Logging utility must declare this in the capabili [Initialization]: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization Servers built with the C# SDK always declare the logging capability. Doing so does not obligate the server -to send log messages—only allows it. Note that [stateless](xref:sessions) MCP servers might not be capable of sending log +to send log messages—only allows it. Note that [stateless](xref:stateless) MCP servers might not be capable of sending log messages as there might not be an open connection to the client on which the log messages could be sent. The C# SDK provides an extension method on to allow the diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md index e8e77c534..fe896292a 100644 --- a/docs/concepts/progress/progress.md +++ b/docs/concepts/progress/progress.md @@ -16,7 +16,7 @@ However, progress tracking is defined in the MCP specification as a general feat This project illustrates the common case of a server tool that performs a long-running operation and sends progress updates to the client. > [!NOTE] -> Progress notifications are sent inline as part of the response to a request — they are not unsolicited. Progress tracking works in both [stateless and stateful](xref:sessions) modes as well as stdio. +> Progress notifications are sent inline as part of the response to a request — they are not unsolicited. Progress tracking works in both [stateless and stateful](xref:stateless) modes as well as stdio. ### Server Implementation diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 04c13f210..8b11e9162 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -197,7 +197,7 @@ foreach (var message in result.Messages) ### Prompt list change notifications -Servers can dynamically add, remove, or modify prompts at runtime and notify connected clients. These are unsolicited notifications, so they require [stateful mode or stdio](xref:sessions) — [stateless](xref:sessions#stateless-mode-recommended) servers cannot send unsolicited notifications. +Servers can dynamically add, remove, or modify prompts at runtime and notify connected clients. These are unsolicited notifications, so they require [stateful mode or stdio](xref:stateless) — [stateless](xref:stateless#stateless-mode-recommended) servers cannot send unsolicited notifications. #### Sending notifications from the server diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 332cd1a5d..65ee0c625 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -57,7 +57,7 @@ await using var client = await McpClient.CreateAsync(transport, options); ### Requesting roots from the server -Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:sessions) — it is not available in [stateless mode](xref:sessions#stateless-mode-recommended). +Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:stateless) — it is not available in [stateless mode](xref:stateless#stateless-mode-recommended). ```csharp [McpServerTool, Description("Lists the user's project roots")] diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index a93508e25..4f14a4ee0 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -12,7 +12,7 @@ MCP [sampling] allows servers to request LLM completions from the client. This e [sampling]: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling > [!NOTE] -> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:sessions). Sampling is not available in [stateless mode](xref:sessions#stateless-mode-recommended) because stateless servers cannot send requests to clients. +> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:stateless). Sampling is not available in [stateless mode](xref:stateless#stateless-mode-recommended) because stateless servers cannot send requests to clients. ### How sampling works diff --git a/docs/concepts/sessions/sessions.md b/docs/concepts/stateless/stateless.md similarity index 99% rename from docs/concepts/sessions/sessions.md rename to docs/concepts/stateless/stateless.md index 3187cdc6d..20f760682 100644 --- a/docs/concepts/sessions/sessions.md +++ b/docs/concepts/stateless/stateless.md @@ -1,11 +1,11 @@ --- -title: Sessions +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: sessions +uid: stateless --- -# Sessions +# 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 to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 30fd8dcd0..4e0001cd5 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -9,8 +9,8 @@ items: uid: capabilities - name: Transports uid: transports - - name: Sessions - uid: sessions + - name: Stateless and Stateful + uid: stateless - name: Ping uid: ping - name: Progress diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index c3928d8a2..503307e66 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -262,7 +262,7 @@ if (result.IsError is true) ### Tool list change notifications -Servers can dynamically add, remove, or modify tools at runtime. When the tool list changes, the server notifies connected clients so they can refresh their tool list. These are unsolicited notifications, so they require [stateful mode or stdio](xref:sessions) — [stateless](xref:sessions#stateless-mode-recommended) servers cannot send unsolicited notifications. +Servers can dynamically add, remove, or modify tools at runtime. When the tool list changes, the server notifies connected clients so they can refresh their tool list. These are unsolicited notifications, so they require [stateful mode or stdio](xref:stateless) — [stateless](xref:stateless#stateless-mode-recommended) servers cannot send unsolicited notifications. #### Sending notifications from the server diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 2430c5bac..df42299d5 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -131,13 +131,13 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:sessions#forward-and-backward-compatibility). See [Sessions](xref:sessions) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:sessions#cancellation-and-disposal) behavior during shutdown. +By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. #### How messages flow In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes. -In stateful mode, the client can also open a long-lived GET request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (e.g., resource-changed notifications from a background watcher). In stateless mode, the GET endpoint is not mapped, so every message must be part of a POST response. See [How Streamable HTTP delivers messages](xref:sessions#how-streamable-http-delivers-messages) for a detailed breakdown. +In stateful mode, the client can also open a long-lived GET request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (e.g., resource-changed notifications from a background watcher). In stateless mode, the GET endpoint is not mapped, so every message must be part of a POST response. See [How Streamable HTTP delivers messages](xref:stateless#how-streamable-http-delivers-messages) for a detailed breakdown. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: @@ -147,7 +147,7 @@ A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] app.MapMcp("/mcp"); ``` -When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:sessions#legacy-sse-transport)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). +When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:stateless#legacy-sse-transport)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). ### SSE transport (legacy) @@ -186,7 +186,7 @@ SSE-specific configuration options: The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9003`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. -**Why SSE is disabled by default.** The SSE transport separates request and response channels: clients POST JSON-RPC messages to `/message` and receive all responses through a long-lived GET SSE stream on `/sse`. Because the POST endpoint returns `202 Accepted` immediately — before the handler even runs — there is **no HTTP-level backpressure** on handler concurrency. A client (or attacker) can flood the server with tool calls without waiting for prior requests to complete. In contrast, Streamable HTTP holds each POST response open until the handler finishes, providing natural backpressure. See [Request backpressure](xref:sessions#request-backpressure) for a detailed comparison and mitigations if you must use SSE. +**Why SSE is disabled by default.** The SSE transport separates request and response channels: clients POST JSON-RPC messages to `/message` and receive all responses through a long-lived GET SSE stream on `/sse`. Because the POST endpoint returns `202 Accepted` immediately — before the handler even runs — there is **no HTTP-level backpressure** on handler concurrency. A client (or attacker) can flood the server with tool calls without waiting for prior requests to complete. In contrast, Streamable HTTP holds each POST response open until the handler finishes, providing natural backpressure. See [Request backpressure](xref:stateless#request-backpressure) for a detailed comparison and mitigations if you must use SSE. To enable legacy SSE, set `EnableLegacySse` to `true`: @@ -215,7 +215,7 @@ app.MapMcp(); app.Run(); ``` -See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details on SSE session lifetime and configuration. +See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. ### Transport mode comparison @@ -226,13 +226,13 @@ See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for | Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | Session ID via query string, tracked in memory | | Server-to-client requests | ✓ | ✗ (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458)) | ✓ | ✓ | | Unsolicited notifications | ✓ | ✗ | ✓ | ✓ | -| Backpressure | Implicit (stdin/stdout flow control) | ✓ (POST held open until handler completes) | ✓ (POST held open until handler completes) | ✗ (POST returns 202 immediately — see [backpressure](xref:sessions#request-backpressure)) | +| Backpressure | Implicit (stdin/stdout flow control) | ✓ (POST held open until handler completes) | ✓ (POST held open until handler completes) | ✗ (POST returns 202 immediately — see [backpressure](xref:stateless#request-backpressure)) | | Session resumption | N/A | N/A | ✓ | ✗ | | Horizontal scaling | N/A | No constraints | Requires session affinity | Requires session affinity | | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools, IDE integrations | Remote servers, production deployments | Local HTTP debugging, server-to-client features | Legacy client compatibility | -For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:sessions). +For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:stateless). ### In-memory transport @@ -267,4 +267,4 @@ var echo = tools.First(t => t.Name == "echo"); Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` -Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:sessions) for how session behavior varies across transports. +Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index d81afa418..cc39c4732 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -36,4 +36,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | :------------ | :----- | :---------- | | `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. | | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | -| `MCP9003` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — Legacy SSE transport](xref:sessions#legacy-sse-transport) for details. | +| `MCP9003` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 3445a8c87..ec28eff84 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -222,7 +222,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context) await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + - "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for more details.", + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest); return null; } @@ -308,7 +308,7 @@ await WriteJsonRpcErrorAsync(context, await WriteJsonRpcErrorAsync(context, "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + - "See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for more details.", + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest); return null; }