From 63b3dfec6a81c9f2e58b0dac423134f59f215852 Mon Sep 17 00:00:00 2001 From: Pardhu Vavila Date: Wed, 5 Aug 2026 12:46:17 -0700 Subject: [PATCH 1/2] Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving Introduces `HttpServerSessionMode` so a single Streamable HTTP endpoint can serve `initialize`-handshake clients with full sessions while serving `2026-07-28` and later clients statelessly. - Add `HttpServerSessionMode { Stateless, Stateful, StatefulForInitializeClients }` and `HttpServerTransportOptions.SessionMode` (defaults to `Stateless`). - Obsolete `HttpServerTransportOptions.Stateless` (MCP9008) and keep it as a compatibility proxy over `SessionMode`: `true` maps to `Stateless`, `false` maps to `Stateful`, hybrid reads as `false`. Assigning both is allowed and the last assignment wins. - Decide the effective mode per request in `StreamableHttpHandler`: `2026-07-28` requests are refused only in `Stateful` mode, and `StartNewSessionAsync`/`CreateSessionAsync` now take an explicit `serveStatelessly` flag so DI and callback lifetimes follow the request. - Keep GET/DELETE endpoints mapped, legacy SSE permitted, and idle tracking running in hybrid mode. Fixes #1777 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/completions/completions.md | 2 +- docs/concepts/elicitation/elicitation.md | 2 +- .../elicitation/samples/server/Program.cs | 5 +- docs/concepts/filters.md | 6 +- docs/concepts/getting-started.md | 2 +- docs/concepts/httpcontext/samples/Program.cs | 3 +- .../logging/samples/server/Program.cs | 5 +- docs/concepts/mrtr/mrtr.md | 4 +- docs/concepts/pagination/pagination.md | 2 +- .../progress/samples/server/Program.cs | 3 +- docs/concepts/prompts/prompts.md | 2 +- docs/concepts/resources/resources.md | 6 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 89 +++-- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 8 +- docs/list-of-diagnostics.md | 3 +- .../AspNetCoreMcpPerSessionTools/Program.cs | 5 +- .../AspNetCoreMcpPerSessionTools/README.md | 4 +- samples/AspNetCoreMcpServer/Program.cs | 5 +- samples/EverythingServer/Program.cs | 5 +- samples/ProtectedMcpServer/Program.cs | 3 +- src/Common/Obsoletions.cs | 6 +- .../HttpServerSessionMode.cs | 65 ++++ .../HttpServerTransportOptions.cs | 78 +++-- .../IdleTrackingBackgroundService.cs | 2 +- .../McpEndpointRouteBuilderExtensions.cs | 6 +- .../StreamableHttpHandler.cs | 72 ++-- .../HttpMcpServerBuilderExtensionsTests.cs | 4 +- .../HttpServerTransportOptionsTests.cs | 45 +++ .../July2026ProtocolHttpHandlerTests.cs | 8 +- .../July2026ProtocolHybridSessionModeTests.cs | 309 ++++++++++++++++++ .../July2026ProtocolStatefulFallbackTests.cs | 4 +- .../MapMcpSseTests.cs | 6 +- .../MapMcpStreamableHttpTests.cs | 6 +- .../MapMcpTests.Mrtr.cs | 2 +- .../MapMcpTests.cs | 2 +- .../MrtrProtocolTests.cs | 2 +- .../OAuth/OAuthTestBase.cs | 2 +- .../RawHttpConformanceTests.cs | 2 +- .../RequestAbortCancellationTests.cs | 4 +- .../ResumabilityIntegrationTestsBase.cs | 4 +- .../SessionMigrationTests.cs | 2 +- .../SseIntegrationTests.cs | 12 +- .../StatelessServerTests.cs | 16 +- .../StreamableHttpServerConformanceTests.cs | 2 +- .../Program.cs | 3 +- .../Program.cs | 7 +- 49 files changed, 687 insertions(+), 154 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 16a16952e..0ac808ac5 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(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithResources() .WithCompleteHandler(async (ctx, ct) => diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 841c8a664..735ef1c48 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs index b10dd7e74..a25689ae6 100644 --- a/docs/concepts/elicitation/samples/server/Program.cs +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -1,4 +1,5 @@ using Elicitation.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ .WithHttpTransport(options => { // 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; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly for forward compatibility in case the default changes. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index aac23ea2a..d1c9b721f 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -411,7 +411,7 @@ To enable authorization support, call `AddAuthorizationFilters()` when configuri ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` @@ -511,7 +511,7 @@ This allows you to implement logging, metrics, or other cross-cutting concerns t ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithRequestFilters(requestFilters => { requestFilters.AddListToolsFilter(next => async (context, cancellationToken) => @@ -556,7 +556,7 @@ builder.Services.AddAuthorization(); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .AddAuthorizationFilters() // Required for authorization support .WithTools() diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 73901c14f..cde72639c 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -88,7 +88,7 @@ builder.Services.AddMcpServer() // Stateless mode is recommended for servers that don't need // server-to-client requests like sampling or elicitation. // See the Stateless and Stateful documentation for details. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithToolsFromAssembly(); var app = builder.Build(); diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs index a01602d40..c9d9dc671 100644 --- a/docs/concepts/httpcontext/samples/Program.cs +++ b/docs/concepts/httpcontext/samples/Program.cs @@ -1,4 +1,5 @@ using HttpContext.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs index 48e2905c2..975c3257e 100644 --- a/docs/concepts/logging/samples/server/Program.cs +++ b/docs/concepts/logging/samples/server/Program.cs @@ -1,4 +1,5 @@ using Logging.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ .WithHttpTransport(options => { // Log streaming requires stateful mode because the server pushes log notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - options.Stateless = false; + // to clients. Set SessionMode = HttpServerSessionMode.Stateful explicitly for forward compatibility. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); // .WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult()); diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 8730e6f37..a0df3e68d 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -29,7 +29,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `SessionMode = HttpServerSessionMode.Stateful` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) accepts `2026-07-28` statelessly — and therefore enables MRTR — while still issuing sessions to `initialize`-handshake clients on the same endpoint. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -391,4 +391,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two Under `2025-11-25` and earlier, stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) serves `2026-07-28` requests through the stateless path — the `2026-07-28` / `Stateless` row — while `initialize`-handshake clients on the same endpoint follow the `2025-11-25` / `Stateful` row. diff --git a/docs/concepts/pagination/pagination.md b/docs/concepts/pagination/pagination.md index 3276fcf3a..0f601921d 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(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithListResourcesHandler(async (ctx, ct) => { const int pageSize = 10; diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs index cfff45808..ef9c67c64 100644 --- a/docs/concepts/progress/samples/server/Program.cs +++ b/docs/concepts/progress/samples/server/Program.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.AspNetCore; using Progress.Tools; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 062f02bdb..5dcc23662 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(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithPrompts(); ``` diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 6b08f7247..275057881 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(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithResources() .WithResources(); ``` @@ -209,8 +209,8 @@ Register subscription handlers when building the server: ```csharp builder.Services.AddMcpServer() // Subscriptions require stateful mode because the server pushes change notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - .WithHttpTransport(o => o.Stateless = false) + // to clients. Set SessionMode = HttpServerSessionMode.Stateful explicitly for forward compatibility. + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index a7a8ed2e4..16bd995f3 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -112,7 +112,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 3dcbd44c5..b24cff60f 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -129,7 +129,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 899fd1864..da4e1cb0d 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -7,9 +7,9 @@ uid: stateless # Stateless and stateful mode -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting 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 push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). +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 **. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). -When sessions are enabled (`Stateless = false`), 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)). +When sessions are enabled (`SessionMode = HttpServerSessionMode.Stateful`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -20,29 +20,36 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a - 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`). +- Does your server need sessions for existing clients but must also serve `2026-07-28` clients natively on the same endpoint? → **Use [hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only)** (`options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients`). +- Otherwise → **Use stateless** (`options.SessionMode = HttpServerSessionMode.Stateless`). > [!NOTE] -> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to to match the new wire format. You can still opt back into sessions with `SessionMode = HttpServerSessionMode.Stateful` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `SessionMode` property is the single most important setting for forward-proofing your MCP server. The default is now (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `SessionMode` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`HttpServerSessionMode.Stateless`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this configuration lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. +- **`HttpServerSessionMode.Stateful`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that in this mode a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. + +- **`HttpServerSessionMode.StatefulForInitializeClients`** — the migration mode. `initialize`-handshake clients get full sessions while `2026-07-28` and later clients are served statelessly on the same endpoint, so you can adopt the new revision progressively instead of waiting for every client to migrate. See [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). > [!TIP] -> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +> If you're not sure which to pick, leave the default (`HttpServerSessionMode.Stateless`). You can switch to `HttpServerSessionMode.Stateful` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + + +> [!NOTE] +> The older `bool` property is obsolete ([`MCP9008`](xref:list-of-diagnostics#obsolete-apis)) because it cannot express hybrid mode. It remains a compatibility proxy over `SessionMode`: assigning `true` selects `Stateless` and assigning `false` selects `Stateful`, while reading it returns `true` only for `Stateless`. Both properties update the same underlying value, so the last assignment wins. ### The 2026-07-28 protocol revision -The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). +The `2026-07-28` protocol revision goes further than stateless mode: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. +**Server side.** With `SessionMode = HttpServerSessionMode.Stateless` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `SessionMode = HttpServerSessionMode.Stateful`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. `SessionMode = HttpServerSessionMode.StatefulForInitializeClients` opts out of that downgrade and serves the `2026-07-28` request statelessly instead — see [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request, in every mode. **Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. @@ -80,7 +87,7 @@ With the default **Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`. -**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `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`. +**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `SessionMode = HttpServerSessionMode.Stateful`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `SessionMode = HttpServerSessionMode.Stateless`. ## Stateless mode (recommended) @@ -94,7 +101,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -105,7 +112,7 @@ app.Run(); ### What stateless mode changes -When is `true`: +When is : - 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 @@ -145,7 +152,7 @@ This means servers that need user confirmation ([elicitation](xref:elicitation)) ## Stateful mode (sessions) -When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is , the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the `GET` stream @@ -166,6 +173,42 @@ Use stateful mode when your server needs one or more of: The [deployment considerations](#deployment-considerations) section lists real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set. +## Hybrid mode (sessions for initialize clients only) + + serves both eras on a single endpoint: clients that send the `initialize` handshake (`2025-11-25` and earlier) get a full stateful session, while clients using `2026-07-28` and later are served statelessly, per request. It exists so an existing stateful server can adopt the new protocol revision progressively instead of waiting for every client to migrate first. + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + }) + .WithTools(); +``` + +### What each client sees + +| Client | Behavior | +|---|---| +| `2025-11-25` and earlier (sends `initialize`) | Full stateful session, `Mcp-Session-Id` issued and echoed, `GET` and `DELETE` available, server-to-client requests supported | +| `2026-07-28` and later (sends `server/discover`) | Served per request with no session ID minted or echoed; `GET` and `DELETE` return `405 Method Not Allowed`; no downgrade to `initialize` | + +Without hybrid mode, `HttpServerSessionMode.Stateful` refuses a `2026-07-28` request with `-32022 UnsupportedProtocolVersion` so that dual-path clients fall back to `initialize`. Hybrid mode removes that refusal for clients that don't want (or can't perform) the downgrade. + +### What stays unavailable to `2026-07-28` clients + +A `2026-07-28` request has no session even on a hybrid endpoint, so all of the [stateless mode restrictions](#what-stateless-mode-changes) still apply to it: no [unsolicited notifications](#how-streamable-http-delivers-messages), no resource subscriptions, no server-initiated ping, and no per-client isolation. Use [MRTR](xref:mrtr) for elicitation (and the deprecated sampling and roots) on that half of the endpoint. Legacy sessions on the same endpoint keep all of those features. + +### Lifetimes + +Because the effective mode is decided per request, DI and callback lifetimes follow the request rather than the endpoint: + +- `2026-07-28` requests resolve services from `HttpContext.RequestServices` with request scoping disabled, exactly like [stateless HTTP](#stateless-http). +- `initialize`-handshake sessions resolve services from the application provider and scope each request, exactly like [stateful HTTP](#stateful-http). +- runs once per session for `initialize`-handshake clients and once per HTTP request for `2026-07-28` clients. + +Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandler`, and so on) continue to govern the session half of the endpoint and are ignored for `2026-07-28` requests. + ## Comparison | Consideration | Stateless | Stateful | @@ -183,6 +226,9 @@ The [deployment considerations](#deployment-considerations) section lists real c | **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate | | **[Tasks](xref:tasks)** | Supported — shared task store, no per-session isolation | Supported — task store scoped per session | +> [!NOTE] +> [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only) doesn't add a third column: each request follows the **Stateless** column when the client negotiated `2026-07-28` and the **Stateful** column when the client used the `initialize` handshake. + ## Transports and sessions ### Streamable HTTP @@ -390,7 +436,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Recommended for servers that don't need sessions. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; // --- Options below only apply to stateful (non-stateless) mode --- @@ -417,7 +463,8 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` protocol revision. | +| | | `Stateless` | Selects how the server tracks state between requests: `Stateless` (no sessions), `Stateful` (sessions for every client, `2026-07-28` refused), or `StatefulForInitializeClients` ([hybrid](#hybrid-mode-sessions-for-initialize-clients-only)). | +| | `bool` | `true` | _Obsolete (`MCP9008`)._ Compatibility proxy over `SessionMode`: `true` maps to `Stateless`, `false` maps to `Stateful`, and hybrid mode reads as `false`. Use `SessionMode` instead. | | | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | | | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | | | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. | @@ -426,7 +473,7 @@ builder.Services.AddMcpServer() | | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | | | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | -The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. +The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `SessionMode = HttpServerSessionMode.Stateless`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions @@ -457,7 +504,7 @@ In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP reque builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => { // This runs on every request in stateless mode, so you can use the @@ -746,7 +793,7 @@ In stateless mode, each HTTP request is its own "session", so `mcp.server.sessio 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 (`MCP9004`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. > [!NOTE] -> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. +> Setting `EnableLegacySse = true` while `SessionMode = HttpServerSessionMode.Stateless` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. ### How SSE sessions work @@ -778,7 +825,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session migration is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.SessionMigrationHandler = new MySessionMigrationHandler(); }); ``` @@ -806,7 +853,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session resumability is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = new MyEventStreamStore(); }); ``` diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index df8887cb2..3a68d619b 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(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithTools(); ``` diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 68331930e..237a7b2b3 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -174,7 +174,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Recommended for servers that don't need server-to-client requests. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -183,7 +183,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. 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, see [Stateless and Stateful](xref:stateless). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. 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, see [Stateless and Stateful](xref:stateless). #### Host name validation @@ -297,7 +297,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** and is marked `[Obsolete]` (diagnostic `MCP9004`). 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 `MCP9004`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `SessionMode = HttpServerSessionMode.Stateless`. **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. For a detailed comparison and mitigations if you must use SSE, see [Request backpressure](xref:stateless#request-backpressure). @@ -310,7 +310,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // SSE requires stateful mode; opt in explicitly because stateless mode is the default. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; #pragma warning disable MCP9004 // EnableLegacySse is obsolete // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 1ad8206be..abe99a8d8 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -44,5 +44,6 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) for more information. | -| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | +| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when the request is served with a session. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `SessionMode` to `HttpServerSessionMode.Stateless`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | | `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. | +| `MCP9008` | In place | is a two-value flag that cannot express . Use instead. `Stateless` remains a compatibility proxy over `SessionMode`: `true` maps to `Stateless` and `false` maps to `Stateful`. See [Stateless and stateful mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only) for details. | diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index 983d296f2..c7dc3cb59 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -1,4 +1,5 @@ using AspNetCoreMcpPerSessionTools.Tools; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Reflection; @@ -14,8 +15,8 @@ .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; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly for forward compatibility in case the default changes. + options.SessionMode = HttpServerSessionMode.Stateful; // 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 e0d968042..9158609a2 100644 --- a/samples/AspNetCoreMcpPerSessionTools/README.md +++ b/samples/AspNetCoreMcpPerSessionTools/README.md @@ -65,9 +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 + // Per-session tool filtering requires stateful mode. Set SessionMode = HttpServerSessionMode.Stateful // explicitly for forward compatibility in case the default changes. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { var toolCategory = GetToolCategoryFromRoute(httpContext); diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 3441083bb..40965db41 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,4 +1,5 @@ using Azure.Monitor.OpenTelemetry.AspNetCore; +using ModelContextProtocol.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -26,10 +27,10 @@ // Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send // a server-to-client sampling request. This requires stateful (session-based) mode. Set -// Stateless = false explicitly for forward compatibility in case the default changes. +// SessionMode = HttpServerSessionMode.Stateful 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(o => o.Stateless = false) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithTools() .WithTools() .WithTools() diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index f8c975212..ac3946bc6 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,6 +3,7 @@ using EverythingServer.Resources; using EverythingServer.Tools; using Microsoft.Extensions.AI; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -58,8 +59,8 @@ .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; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly for forward compatibility in case the default changes. + options.SessionMode = HttpServerSessionMode.Stateful; // Add a RunSessionHandler to remove all subscriptions for the session when it ends #pragma warning disable MCPEXP002 // RunSessionHandler is experimental diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index f539e73bb..9c856d9e2 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -2,6 +2,7 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.AspNetCore; using ProtectedMcpServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; @@ -93,7 +94,7 @@ // 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; + options.SessionMode = HttpServerSessionMode.Stateless; }); // Configure HttpClientFactory for weather.gov API diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 6ecab2b3c..183b688c9 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -44,10 +44,14 @@ internal static class Obsoletions public const string DeprecatedLogging_Message = "The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; public const string LegacyStatefulHttp_DiagnosticId = "MCP9006"; - public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007"; public const string AuthorizationRedirectDelegate_Message = "AuthorizationRedirectDelegate cannot provide the RFC 9207 issuer and is retained for compatibility only. Use AuthorizationCallbackHandler instead."; public const string AuthorizationRedirectDelegate_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + + public const string StatelessProperty_DiagnosticId = "MCP9008"; + public const string StatelessProperty_Message = "HttpServerTransportOptions.Stateless cannot express the hybrid session mode. Use HttpServerTransportOptions.SessionMode instead."; + public const string StatelessProperty_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs new file mode 100644 index 000000000..4708272d6 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs @@ -0,0 +1,65 @@ +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Specifies how the Streamable HTTP transport tracks state between requests. +/// +/// +/// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions +/// (SEP-2567 removed Mcp-Session-Id, and SEP-2575 removed the initialize handshake), so requests +/// using that revision or later can only ever be served statelessly. This enumeration selects how the server +/// reconciles that requirement with clients that still rely on the initialize handshake. +/// +public enum HttpServerSessionMode +{ + /// + /// The server never tracks state between requests, allowing for load balancing without session affinity. + /// This is the default. + /// + /// + /// is , the + /// Mcp-Session-Id header is unused, and + /// are invoked once per request, and the + /// GET, DELETE, and /sse endpoints are unavailable. Unsolicited server-to-client messages and all + /// server-to-client requests are unsupported because any response might arrive at another ASP.NET Core + /// application process. Client sampling, elicitation, and roots capabilities are disabled because the + /// server cannot make requests; use MRTR + /// instead. + /// + Stateless, + + /// + /// The server tracks a long-lived session for every client, which requires session affinity. + /// + /// + /// Requests using the 2026-07-28 or later protocol revision are refused with a + /// -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. Use + /// to serve those clients natively instead of forcing a downgrade. + /// + Stateful, + + /// + /// The server tracks a long-lived session for clients that use the initialize handshake and serves + /// clients using the 2026-07-28 or later protocol revision statelessly on the same endpoint. + /// + /// + /// + /// This hybrid mode allows an application to adopt the latest protocol revision progressively rather than + /// waiting for every client to migrate. Clients using the 2025-11-25 or earlier revisions get a full + /// stateful session with an Mcp-Session-Id and continue to use the GET and DELETE endpoints, while + /// clients using the 2026-07-28 or later revisions are served per request with no session ID minted + /// or echoed, and receive 405 Method Not Allowed for GET and DELETE, exactly as in + /// mode. + /// + /// + /// Because a 2026-07-28 request has no session, the session-only features listed on + /// remain unavailable to those clients even though other clients on the same + /// endpoint have sessions. is invoked once + /// per session for initialize-handshake clients and once per request for 2026-07-28 and later + /// clients. + /// + /// + StatefulForInitializeClients, +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 024772240..c51a08dd0 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -18,9 +18,11 @@ public class HttpServerTransportOptions /// 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. + /// In stateful mode, 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. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. /// public Func? ConfigureSessionOptions { get; set; } @@ -45,6 +47,44 @@ public class HttpServerTransportOptions [System.Diagnostics.CodeAnalysis.Experimental(Experimentals.RunSessionHandler_DiagnosticId, UrlFormat = Experimentals.RunSessionHandler_Url)] public Func? RunSessionHandler { get; set; } + /// + /// Gets or sets a value that indicates how the server tracks state between requests. + /// + /// + /// One of the values. The default is + /// as of the 2026-07-28 protocol revision (SEP-2567). + /// + /// + /// + /// doesn't track state between requests, allowing for load + /// balancing without session affinity. will be null, the + /// "MCP-Session-Id" header will not be used, the will be called once for + /// each request, and the GET, DELETE, and "/sse" endpoints will be disabled. Unsolicited server-to-client + /// messages and all server-to-client requests are also unsupported, because any responses might arrive at + /// another ASP.NET Core application process. Client sampling, elicitation, and roots capabilities are also + /// disabled, because the server cannot make requests. + /// + /// + /// tracks a session for every client, which requires session + /// affinity. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports + /// sessions: the revision removed Mcp-Session-Id (SEP-2567), so such a request is refused with a + /// -32022 UnsupportedProtocolVersion error, and a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. + /// + /// + /// avoids that downgrade by serving + /// 2026-07-28 and later requests statelessly on the same endpoint while initialize-handshake + /// clients still get full sessions. Session-only features remain unavailable to the stateless half of the + /// endpoint; use MRTR for + /// elicitation there. + /// + /// + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored + /// in every mode; the server must not mint or echo session IDs for those revisions. + /// + /// + public HttpServerSessionMode SessionMode { get; set; } = HttpServerSessionMode.Stateless; + /// /// Gets or sets a value that indicates whether the server runs in a stateless mode that doesn't track state between requests, /// allowing for load balancing without session affinity. @@ -55,22 +95,19 @@ public class HttpServerTransportOptions /// set to only when you need to support legacy clients that rely on session affinity. /// /// - /// If , will be null, and the "MCP-Session-Id" header will not be used, - /// the will be called once for each request, and the "/sse" endpoint will be disabled. - /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses - /// might arrive at another ASP.NET Core application process. - /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. - /// - /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: - /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served - /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to - /// the initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; - /// the server must not mint or echo session IDs for those revisions. - /// + /// This property is a compatibility proxy over . Reading it returns + /// only when is , + /// so reads as . + /// Assigning selects and assigning + /// selects . Because both properties + /// update the same underlying value, the last assignment wins when both are configured. /// - public bool Stateless { get; set; } = true; + [Obsolete(Obsoletions.StatelessProperty_Message, DiagnosticId = Obsoletions.StatelessProperty_DiagnosticId, UrlFormat = Obsoletions.StatelessProperty_Url)] + public bool Stateless + { + get => SessionMode is HttpServerSessionMode.Stateless; + set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; + } /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -94,8 +131,9 @@ public class HttpServerTransportOptions /// built-in backpressure. /// /// - /// Setting this to while is also - /// throws an at startup, because SSE requires in-memory session state. + /// Setting this to while is + /// throws an at + /// startup, because SSE requires in-memory session state. /// /// /// This property can also be enabled via the ModelContextProtocol.AspNetCore.EnableLegacySse diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index b11fe81cd..de11d8a2a 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -36,7 +36,7 @@ public IdleTrackingBackgroundService( public override Task StartAsync(CancellationToken cancellationToken) { // In stateless mode there are no sessions to track, so skip starting the periodic timer entirely. - if (_options.Value.Stateless) + if (_options.Value.SessionMode is HttpServerSessionMode.Stateless) { return Task.CompletedTask; } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index c95a5a835..e5fc3fa4d 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -32,7 +32,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo var options = streamableHttpHandler.HttpServerTransportOptions; #pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled - if (options.Stateless && options.EnableLegacySse) + if (options.SessionMode is HttpServerSessionMode.Stateless && options.EnableLegacySse) { throw new InvalidOperationException( "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + @@ -50,10 +50,12 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - if (!options.Stateless) + if (options.SessionMode is not HttpServerSessionMode.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. + // In StatefulForInitializeClients mode both endpoints stay mapped for initialize-handshake clients; + // the handlers reject 2026-07-28 and later requests with 405 Method Not Allowed. streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f0b0b1a12..50c20a792 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler( /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path - /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a fully stateful server so a dual-path client falls + /// back to the initialize handshake instead of retrying the 2026-07-28 version. /// private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; @@ -53,6 +53,14 @@ internal sealed class StreamableHttpHandler( public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; + /// + /// Returns when no request served by this endpoint can have a session. In + /// mode this is + /// even though individual 2026-07-28 and later requests are still served statelessly, because the + /// endpoint as a whole still tracks sessions for initialize-handshake clients. + /// + private bool IsStatelessOnly => HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateless; + public async Task HandlePostRequestAsync(HttpContext context) { // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. @@ -100,7 +108,7 @@ await WriteJsonRpcErrorAsync(context, // Validated after the body parse (rather than first) so the rejection can echo the request's // JSON-RPC id: every error response for a parseable request MUST carry its id. var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId); return; @@ -191,7 +199,7 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -238,7 +246,7 @@ await WriteJsonRpcErrorAsync(context, private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId) { - if (HttpServerTransportOptions.Stateless) + if (IsStatelessOnly) { await WriteJsonRpcErrorAsync(context, "Bad Request: The Last-Event-ID header is not supported in stateless mode.", @@ -310,7 +318,7 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -354,7 +362,7 @@ await WriteJsonRpcErrorAsync(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. " + + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; @@ -435,17 +443,18 @@ await WriteJsonRpcErrorAsync(context, // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: if (RequiresPerRequestMetadataProtocol(context)) { - if (!HttpServerTransportOptions.Stateless) + if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful) { - // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-path client falls back to the - // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + // The author explicitly opted into sessions for every client, which the 2026-07-28 revision + // cannot provide. Refuse it so a dual-path client falls back to the initialize handshake and + // gets the session it asked for (SEP-2575 fallback semantics). StatefulForInitializeClients + // opts out of that downgrade and serves these requests statelessly instead. await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; } - // The default (stateless) HTTP transport serves these requests natively. - return await StartNewSessionAsync(context); + // Stateless and StatefulForInitializeClients both serve these requests natively, without a session. + return await StartNewSessionAsync(context, serveStatelessly: true); } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -453,20 +462,20 @@ await WriteJsonRpcErrorAsync(context, { // In stateful mode, only allow creating new sessions for initialize requests. // In stateless mode, every request is independent, so we always create a new session. - if (!HttpServerTransportOptions.Stateless && !AllowNewSessionForNonInitializeRequests + if (!IsStatelessOnly && !AllowNewSessionForNonInitializeRequests && 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, " + - "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + + "or enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless if your server doesn't need sessions. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; } - return await StartNewSessionAsync(context); + return await StartNewSessionAsync(context, serveStatelessly: IsStatelessOnly); } - else if (HttpServerTransportOptions.Stateless) + else if (IsStatelessOnly) { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode @@ -491,12 +500,12 @@ private static bool RequiresPerRequestMetadataProtocol(HttpContext context) return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } - private async ValueTask StartNewSessionAsync(HttpContext context) + private async ValueTask StartNewSessionAsync(HttpContext context, bool serveStatelessly) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + if (!serveStatelessly) { sessionId = MakeNewSessionId(); #pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. @@ -525,23 +534,24 @@ private async ValueTask StartNewSessionAsync(HttpContext }; } - return await CreateSessionAsync(context, transport, sessionId); + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, + bool serveStatelessly, Action? configureOptions = null) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + if (serveStatelessly || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (serveStatelessly) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -589,7 +599,7 @@ private async ValueTask MigrateSessionAsync( context.Response.Headers[McpSessionIdHeaderName] = sessionId; - return await CreateSessionAsync(context, transport, sessionId, options => + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly: false, options => { options.KnownClientInfo = initializeParams.ClientInfo; options.KnownClientCapabilities = initializeParams.Capabilities; @@ -916,11 +926,13 @@ metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && } /// - /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that - /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to - /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake - /// (SEP-2575). + /// Refuses a 2026-07-28 (or later) request on a fully stateful server + /// (). Starting with that revision, Streamable HTTP no longer + /// has sessions (SEP-2567), so it cannot honor the author's opt-in to sessions; we return + /// with a supported-versions list that excludes + /// 2026-07-28 and later. A dual-path client then falls back to the initialize handshake (SEP-2575). + /// serves the request statelessly instead + /// of refusing it. /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) { @@ -928,8 +940,8 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + - "Use the initialize handshake with a protocol version that still supports sessions instead.", + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateful). " + + "Use the initialize handshake with a protocol version that still supports sessions instead, or set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.StatefulForInitializeClients to serve this version statelessly.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs index 6f36d1421..c18df0ee8 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -190,7 +190,7 @@ public async Task IdleTrackingBackgroundService_DoesNotStartTimer_WhenStateless( { Builder.Services .AddMcpServer() - .WithHttpTransport(options => options.Stateless = true); + .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless); using var app = Builder.Build(); @@ -211,7 +211,7 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() { Builder.Services .AddMcpServer() - .WithHttpTransport(options => options.Stateless = false); + .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); using var app = Builder.Build(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs new file mode 100644 index 000000000..2fd22caeb --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs @@ -0,0 +1,45 @@ +namespace ModelContextProtocol.AspNetCore.Tests; + +public class HttpServerTransportOptionsTests +{ + [Fact] + public void SessionMode_DefaultsToStateless() + { + Assert.Equal(HttpServerSessionMode.Stateless, new HttpServerTransportOptions().SessionMode); + } + +#pragma warning disable MCP9008 // Stateless is obsolete; these tests verify the compatibility proxy. + [Theory] + [InlineData(true, HttpServerSessionMode.Stateless)] + [InlineData(false, HttpServerSessionMode.Stateful)] + public void SettingStateless_SelectsEquivalentSessionMode(bool stateless, HttpServerSessionMode expected) + { + var options = new HttpServerTransportOptions { Stateless = stateless }; + Assert.Equal(expected, options.SessionMode); + } + + [Theory] + [InlineData(HttpServerSessionMode.Stateless, true)] + [InlineData(HttpServerSessionMode.Stateful, false)] + [InlineData(HttpServerSessionMode.StatefulForInitializeClients, false)] + public void ReadingStateless_ReflectsSessionMode(HttpServerSessionMode sessionMode, bool expected) + { + var options = new HttpServerTransportOptions { SessionMode = sessionMode }; + Assert.Equal(expected, options.Stateless); + } + + [Fact] + public void AssigningBothProperties_DoesNotThrow_AndLastAssignmentWins() + { + var options = new HttpServerTransportOptions(); + + options.Stateless = false; + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode); + + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + options.Stateless = true; + Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode); + } +#pragma warning restore MCP9008 +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index ab9d04b70..365cf281d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -24,10 +24,10 @@ private async Task StartAsync(bool stateless = false) options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" }; }).WithHttpTransport(options => { - // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with + // SessionMode = HttpServerSessionMode.Stateful maps the GET/DELETE endpoints and opts the author into sessions. Starting with // the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is - // refused on a session-enabled server. Stateless = true (the default) serves them natively. - options.Stateless = stateless; + // refused on a session-enabled server. SessionMode = HttpServerSessionMode.Stateless (the default) serves them natively. + options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; }); _app = Builder.Build(); @@ -69,7 +69,7 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), - // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that + // so the server cannot honor it when configured with sessions (SessionMode = HttpServerSessionMode.Stateful). The server refuses that // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back // to the initialize handshake. await StartAsync(stateless: false); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs new file mode 100644 index 000000000..e612a47eb --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs @@ -0,0 +1,309 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// End-to-end coverage for : a single endpoint +/// that serves initialize-handshake clients with full stateful sessions while serving 2026-07-28 +/// and later clients statelessly, without forcing them to downgrade +/// (). +/// +[McpServerToolType] +public class July2026ProtocolHybridSessionModeTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + private int _configureSessionOptionsCount; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [McpServerTool(Name = "greet")] + public static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "greet_via_elicit")] + public static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) + { + // Server→client requests only work over a stateful session, so this proves the initialize-handshake + // half of a hybrid endpoint keeps its session even though the endpoint also serves stateless requests. + var elicitResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new(), + }, cancellationToken); + + var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true + ? answer.GetString() + : "stranger"; + + return $"Hello, {name}!"; + } + + [McpServerTool(Name = "scope_state")] + public static string ScopeState(ScopedService scopedService) => scopedService.State ?? ""; + + private async Task StartHybridServerAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHybridSessionModeTests), Version = "1" }; + }) + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => + { + Interlocked.Increment(ref _configureSessionOptionsCount); + return Task.CompletedTask; + }; + }) + .WithTools(); + + Builder.Services.AddScoped(); + + _app = Builder.Build(); + + _app.Use(next => context => + { + context.RequestServices.GetRequiredService().State = "From request middleware!"; + return next(context); + }); + + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private Task ConnectClientAsync(string? protocolVersion = null, Action? configureClient = null) + { + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + // A null ProtocolVersion prefers 2026-07-28 and probes with server/discover before considering a + // fallback to the initialize handshake. Pinning an older version forces the initialize handshake. + var clientOptions = new McpClientOptions { ProtocolVersion = protocolVersion }; + configureClient?.Invoke(clientOptions); + return McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ModernAndLegacyClients_ShareOneEndpoint_AndModernDoesNotDowngrade() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The whole point of the hybrid mode: the default client keeps 2026-07-28 instead of downgrading. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, modernClient.NegotiatedProtocolVersion); + Assert.Null(modernClient.SessionId); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, legacyClient.NegotiatedProtocolVersion); + Assert.False(string.IsNullOrEmpty(legacyClient.SessionId)); + + // Both halves of the endpoint remain usable while the other is connected. + var modernResult = await modernClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Modern" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Modern!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + var legacyResult = await legacyClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Legacy" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Legacy!", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task LegacyClient_OnHybridServer_StillSupportsServerToClientElicitation() + { + await StartHybridServerAsync(); + + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion, options => + { + options.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(), + }, + }); + }); + + var result = await legacyClient.CallToolAsync("greet_via_elicit", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError is not true); + Assert.Equal("Hello, Bob!", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task ModernRequests_UseRequestScopedServices_WhileLegacySessionsUseApplicationServices() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // Stateless requests resolve services from HttpContext.RequestServices, so the tool observes the state + // that the ASP.NET Core middleware set on the request-scoped service. + var modernResult = await modernClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("From request middleware!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + // Stateful sessions outlive the HTTP request, so they scope requests off the application services + // instead and never see the middleware's request-scoped state. + var legacyResult = await legacyClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task ConfigureSessionOptions_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(); + + var beforeLegacyConnect = Volatile.Read(ref _configureSessionOptionsCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The initialize request creates the session; notifications/initialized reuses it. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _configureSessionOptionsCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + + // Subsequent requests reuse the session, so the callback does not run again. + Assert.Equal(0, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyCalls); + + await using var modernClient = await ConnectClientAsync(); + + var beforeModernCall = Volatile.Read(ref _configureSessionOptionsCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + + // Each 2026-07-28 POST creates a fresh per-request server, so the callback runs again. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeModernCall); + } + + [Fact] + public async Task ModernPost_DoesNotMintSessionId_WhileLegacyInitializeDoes() + { + await StartHybridServerAsync(); + + using var modernResponse = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, mcpMethod: "server/discover"); + Assert.Equal(HttpStatusCode.OK, modernResponse.StatusCode); + Assert.False(modernResponse.Headers.Contains("Mcp-Session-Id"), "2026-07-28 responses must not include Mcp-Session-Id."); + + using var legacyResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + Assert.Equal(HttpStatusCode.OK, legacyResponse.StatusCode); + Assert.False(string.IsNullOrEmpty(Assert.Single(legacyResponse.Headers.GetValues("Mcp-Session-Id")))); + } + + [Fact] + public async Task ModernPost_IgnoresMcpSessionIdHeader() + { + await StartHybridServerAsync(); + + // SEP-2567 removed sessions from the 2026-07-28 revision, so a stray session ID must neither be honored + // nor looked up against the stateful session manager the hybrid endpoint keeps for legacy clients. + using var response = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, + mcpMethod: "server/discover", sessionId: "non-existent-session-id"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); + } + + [Fact] + public async Task LegacyGetAndDelete_RemainAvailable_WhileModernGetAndDeleteReturn405() + { + await StartHybridServerAsync(); + + using var initializeResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + var sessionId = Assert.Single(initializeResponse.Headers.GetValues("Mcp-Session-Id")); + + // The GET and DELETE endpoints are still mapped, so legacy clients keep the unsolicited-message stream + // and explicit session termination. + using var legacyGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyGet.StatusCode); + + using var modernGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernGet.StatusCode); + Assert.Equal(["POST"], modernGet.Content.Headers.Allow); + + using var modernDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernDelete.StatusCode); + Assert.Equal(["POST"], modernDelete.Content.Headers.Allow); + + using var legacyDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyDelete.StatusCode); + + // The session is gone, which proves the legacy DELETE was honored rather than short-circuited. + using var afterDelete = await SendAsync(HttpMethod.Post, McpProtocolVersions.November2025ProtocolVersion, ListToolsRequest, sessionId: sessionId); + Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); + } + + private Task SendAsync( + HttpMethod method, + string? protocolVersion, + string? content = null, + string? mcpMethod = null, + string? sessionId = null) + { + var request = new HttpRequestMessage(method, ""); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (protocolVersion is not null) + { + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + } + + if (mcpMethod is not null) + { + request.Headers.Add("Mcp-Method", mcpMethod); + } + + if (sessionId is not null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + + if (content is not null) + { + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + } + + return HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + } + + private static string DiscoverRequest => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"HybridTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; + + private static string InitializeRequest => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"HybridTestClient","version":"1.0"}}} + """; + + private static string ListToolsRequest => """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """; + + public class ScopedService + { + public string? State { get; set; } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index d593ff2f2..bdfa15d58 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -58,9 +58,9 @@ private async Task StartStatefulServerAsync() { options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" }; }) - // Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision, + // SessionMode = HttpServerSessionMode.Stateful is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision, // Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades. - .WithHttpTransport(options => options.Stateless = false) + .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful) .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); _app = Builder.Build(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index 05f9bdff3..f22136a48 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -21,7 +21,7 @@ protected override void ConfigureStateless(HttpServerTransportOptions options) [InlineData("/mcp/secondary")] public async Task Allows_Customizing_Route(string pattern) { - Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); app.MapMcp(pattern); @@ -53,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat Name = "TestCustomRouteServer", Version = "1.0.0", }; - }).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); + }).WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); app.MapMcp(routePattern); @@ -83,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode() return "Complete"; }, options: new() { Name = "polling_tool" }); - Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }).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 889a7daab..a230e4929 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; @@ -236,7 +236,7 @@ public async Task SseEndpoints_AreDisabledByDefault_InStatefulMode() Builder.Services.AddMcpServer().WithHttpTransport(options => { // Stateful mode, but SSE not explicitly enabled. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); @@ -256,7 +256,7 @@ public async Task SseEndpoints_ThrowOnMapMcp_InStatelessMode_WithEnableLegacySse { Builder.Services.AddMcpServer().WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; options.EnableLegacySse = true; }); await using var app = Builder.Build(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 3d8abb0f1..03af131b4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // the handler refuses a request when the server opted into sessions (Stateless = false), so a client pinned + // the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned // to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't // run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures. private const string July2026StatefulStreamableHttpSkipReason = diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index ef6832101..21549e086 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -21,7 +21,7 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : protected virtual void ConfigureStateless(HttpServerTransportOptions options) { - options.Stateless = Stateless; + options.SessionMode = Stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; } protected async Task ConnectAsync( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index aa724bb1b..df5d4bd03 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -293,7 +293,7 @@ static string (RequestContext context) => Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(options => options.Stateless = false); + ]).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 80167b0c9..062e512a5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -62,7 +62,7 @@ protected OAuthTestBase(ITestOutputHelper outputHelper, bool configureMcpMetadat }); Builder.Services.AddAuthorization(); - Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = false); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); } public async ValueTask DisposeAsync() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 8520f929c..0e2a66b89 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -441,7 +441,7 @@ public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Retur request.Headers.Accept.Add(new("text/event-stream")); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // SessionMode = HttpServerSessionMode.Stateless (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE // stream is replaced by subscriptions/listen POST requests. Existing routing in // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 73d000797..f6aad71fe 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -37,7 +37,7 @@ private async Task StartAsync(bool stateless) }) .WithHttpTransport(options => { - options.Stateless = stateless; + options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; }) .WithTools([McpServerTool.Create( async (CancellationToken cancellationToken) => @@ -86,7 +86,7 @@ public async ValueTask DisposeAsync() public async Task July2026Request_AbortFlowsCancellationToToolHandler() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is - // served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era + // served natively only on a stateless server; a SessionMode = HttpServerSessionMode.Stateful server refuses these requests so dual-era // clients fall back to initialize. await StartAsync(stateless: true); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 29e69483e..b94fd22c3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,8 +490,8 @@ protected async Task CreateServerAsync( var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Resumability is a stateful concern; pin Stateless = false now that the new default is true. - options.Stateless = false; + // Resumability is a stateful concern; pin SessionMode = HttpServerSessionMode.Stateful now that the new default is true. + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index 7609e8215..56fe09bb7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ private async Task StartAsync(ISessionMigrationHandler? migrationHandler = null) Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); + }).WithTools(Tools).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs index bd47bdb74..9c6dd34fc 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(options => { options.EnableLegacySse = true; options.Stateless = false; }); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -84,7 +84,7 @@ public async Task ConnectAndReceiveNotification_InMemoryServer() .WithHttpTransport(httpTransportOptions => { httpTransportOptions.EnableLegacySse = true; - httpTransportOptions.Stateless = false; + httpTransportOptions.SessionMode = HttpServerSessionMode.Stateful; #pragma warning disable MCPEXP002 // RunSessionHandler is experimental httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => { @@ -129,7 +129,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() { firstOptionsCallbackCallCount++; }) - .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }) + .WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); Builder.Services.AddMcpServer(options => @@ -173,7 +173,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); @@ -220,7 +220,7 @@ public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); @@ -312,7 +312,7 @@ private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints, b [Fact] public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails() { - Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.SessionMode = HttpServerSessionMode.Stateful; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 092f8f256..03c49130f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -37,7 +37,7 @@ private async Task StartAsync() }) .WithHttpTransport(httpServerTransportOptions => { - httpServerTransportOptions.Stateless = true; + httpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -205,7 +205,7 @@ public async Task ProgressNotifications_Work_InStatelessMode() Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools([McpServerTool.Create( async (IProgress progress) => @@ -247,7 +247,7 @@ public async Task ConfigureSessionOptions_RunsPerRequest_InStatelessMode() Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => { // Dynamically add a tool based on a request header value. @@ -302,7 +302,7 @@ public async Task StatelessMode_DoesNotAdvertise_ListChangedCapabilities() Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) .WithPrompts([McpServerPrompt.Create(() => new GetPromptResult(), new() { Name = "myPrompt" })]) @@ -328,7 +328,7 @@ public async Task SubscriptionsListen_InStatelessMode_GrantsNothing_AndDoesNotHo Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) .WithPrompts([McpServerPrompt.Create(() => new GetPromptResult(), new() { Name = "myPrompt" })]) @@ -392,7 +392,7 @@ public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_StreamsN Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithSubscriptionsListenHandler(async (request, cancellationToken) => { @@ -475,7 +475,7 @@ public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_Advertis Builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) .WithSubscriptionsListenHandler(async (request, cancellationToken) => diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index dd051e4d3..d95bc059a 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -36,7 +36,7 @@ private async Task StartAsync(bool stateless = false) Name = nameof(StreamableHttpServerConformanceTests), Version = "73", }; - }).WithTools(Tools).WithHttpTransport(options => options.Stateless = stateless); + }).WithTools(Tools).WithHttpTransport(options => options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful); _app = Builder.Build(); diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 73f63821e..a13264ce1 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -1,6 +1,7 @@ using ConformanceServer.Prompts; using ConformanceServer.Resources; using ConformanceServer.Tools; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Extensions.Tasks; @@ -51,7 +52,7 @@ private static void ConfigureConformanceMcpServer( services.AddDistributedMemoryCache(); var mcpServerBuilder = services .AddMcpServer() - .WithHttpTransport(options => options.Stateless = stateless) + .WithHttpTransport(options => options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful) .WithDistributedCacheEventStreamStore() .WithTasks( new InMemoryMcpTaskStore diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index f93b6ab2b..a8b8ba95f 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Connections; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Serilog; @@ -378,7 +379,7 @@ private static void HandleStatelessMcp(IApplicationBuilder app) serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService()); serviceCollection.AddRoutingCore(); - serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true); + serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless); var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider()); appBuilder.UseRouting(); @@ -428,8 +429,8 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide .WithHttpTransport(options => { // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). - // Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. + options.SessionMode = HttpServerSessionMode.Stateful; options.EnableLegacySse = true; }); From ceb87feb62f0f3e72bb2578d8a1dd3e3fd81954f Mon Sep 17 00:00:00 2001 From: Pardhu Vavila Date: Wed, 5 Aug 2026 14:10:40 -0700 Subject: [PATCH 2/2] Address hybrid session mode review findings Reject undefined HttpServerSessionMode values through options validation, cover RunSessionHandler lifetimes in hybrid mode, and repair documentation examples and terminology for the three configuration values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/filters.md | 2 + docs/concepts/getting-started.md | 1 + docs/concepts/mrtr/mrtr.md | 2 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 2 + docs/concepts/transports/transports.md | 4 ++ .../HttpMcpServerBuilderExtensions.cs | 1 + .../HttpServerTransportOptions.cs | 6 +++ .../HttpServerTransportOptionsValidator.cs | 21 +++++++++++ .../HttpMcpServerBuilderExtensionsTests.cs | 16 ++++++++ .../July2026ProtocolHybridSessionModeTests.cs | 37 ++++++++++++++++++- 13 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsValidator.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 735ef1c48..b899df200 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index d1c9b721f..80d6d4653 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -546,6 +546,8 @@ services.AddMcpServer() To use authorization features, you must configure authentication and authorization in your ASP.NET Core application and call `AddAuthorizationFilters()`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication("Bearer") diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index cde72639c..8dda00d01 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -78,6 +78,7 @@ dotnet add package ModelContextProtocol.AspNetCore And add the following code: ```csharp +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.ComponentModel; diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index a0df3e68d..5da412b3f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -373,7 +373,7 @@ public static string CloseSupportTicket( ## Compatibility -The SDK supports `InputRequiredException` across two protocol revisions and two session modes: +The SDK supports `InputRequiredException` across both protocol eras and both effective request modes: | Negotiated protocol | Session mode | Behavior | |----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 16bd995f3..fa9e1cd54 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -112,7 +112,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index b24cff60f..7a1f90763 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -129,7 +129,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index da4e1cb0d..08f51a1e2 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -96,6 +96,8 @@ Stateless mode is the recommended default for HTTP-based MCP servers. When enabl ### Enabling stateless mode ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 237a7b2b3..ef2c73e34 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -168,6 +168,8 @@ await using var client = await McpClient.ResumeSessionAsync(transport, new Resum 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 +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() @@ -304,6 +306,8 @@ The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. L To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index a52268341..af297a4f4 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -36,6 +36,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationCallToolFilterGuardSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, HttpServerTransportOptionsSetup>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, HttpServerTransportOptionsValidator>()); if (configureOptions is not null) { diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index c51a08dd0..8dc3522c4 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -41,6 +41,12 @@ public class HttpServerTransportOptions /// of the initializing request with fewer known issues. /// /// + /// In mode, this callback is invoked once per session. In + /// mode, it is invoked once per HTTP request. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. + /// + /// /// This API is experimental and may be removed or change signatures in a future release. /// /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsValidator.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsValidator.cs new file mode 100644 index 000000000..6c3e03d2c --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsValidator.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Validates . +/// +internal sealed class HttpServerTransportOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, HttpServerTransportOptions options) + { + if (!Enum.IsDefined(typeof(HttpServerSessionMode), options.SessionMode)) + { + return ValidateOptionsResult.Fail( + $"The '{nameof(HttpServerTransportOptions)}.{nameof(HttpServerTransportOptions.SessionMode)}' value " + + $"'{options.SessionMode}' is not valid."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs index c18df0ee8..e9c87376b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -77,6 +77,22 @@ public void WithDistributedCacheEventStreamStore_ThrowsOptionsValidationExceptio Assert.StartsWith($"The '{nameof(DistributedCacheEventStreamStoreOptions)}.{nameof(DistributedCacheEventStreamStoreOptions.Cache)}'", ex.Message); } + [Fact] + public void WithHttpTransport_ThrowsOptionsValidationException_WhenSessionModeIsUndefined() + { + Builder.Services + .AddMcpServer() + .WithHttpTransport(options => options.SessionMode = (HttpServerSessionMode)42); + + using var app = Builder.Build(); + + var ex = Assert.Throws( + () => app.Services.GetRequiredService>().Value); + Assert.Contains( + $"'{nameof(HttpServerTransportOptions)}.{nameof(HttpServerTransportOptions.SessionMode)}' value '42' is not valid", + ex.Message); + } + [Fact] public void EventStreamStore_IsPopulatedFromDI_ViaPostConfigure() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs index e612a47eb..3b5a32190 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs @@ -22,6 +22,7 @@ public class July2026ProtocolHybridSessionModeTests(ITestOutputHelper outputHelp { private WebApplication? _app; private int _configureSessionOptionsCount; + private int _runSessionHandlerCount; public async ValueTask DisposeAsync() { @@ -56,7 +57,7 @@ public static async Task GreetViaElicit(McpServer server, CancellationTo [McpServerTool(Name = "scope_state")] public static string ScopeState(ScopedService scopedService) => scopedService.State ?? ""; - private async Task StartHybridServerAsync() + private async Task StartHybridServerAsync(bool trackRunSessionHandler = false) { Builder.Services.AddMcpServer(options => { @@ -70,6 +71,17 @@ private async Task StartHybridServerAsync() Interlocked.Increment(ref _configureSessionOptionsCount); return Task.CompletedTask; }; + + if (trackRunSessionHandler) + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental. + options.RunSessionHandler = async (httpContext, server, cancellationToken) => + { + Interlocked.Increment(ref _runSessionHandlerCount); + await server.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + } }) .WithTools(); @@ -199,6 +211,29 @@ public async Task ConfigureSessionOptions_RunsPerRequestForModernClients_AndOnce Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeModernCall); } + [Fact] + public async Task RunSessionHandler_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(trackRunSessionHandler: true); + + var beforeLegacyConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _runSessionHandlerCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyCalls); + + var beforeModernConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var modernClient = await ConnectClientAsync(); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernConnect); + + var beforeModernCall = Volatile.Read(ref _runSessionHandlerCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernCall); + } + [Fact] public async Task ModernPost_DoesNotMintSessionId_WhileLegacyInitializeDoes() {