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..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 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 across both protocol eras and all three HTTP `SessionMode` configurations. 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..80d6d4653 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) => @@ -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") @@ -556,7 +558,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..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; @@ -88,7 +89,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..5da412b3f 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. @@ -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 | |----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| @@ -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..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 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 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 3dcbd44c5..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 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 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 899fd1864..08f51a1e2 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) @@ -89,12 +96,14 @@ 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() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -105,7 +114,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 +154,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 +175,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 +228,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 +438,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 +465,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 +475,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 +506,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 +795,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 +827,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 +855,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..ef2c73e34 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -168,13 +168,15 @@ 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() .WithHttpTransport(options => { // Recommended for servers that don't need server-to-client requests. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -183,7 +185,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,20 +299,22 @@ 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). To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); 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/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/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..8dc3522c4 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; } @@ -39,12 +41,56 @@ 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. /// /// [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 +101,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 +137,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/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/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..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() { @@ -190,7 +206,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 +227,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..3b5a32190 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs @@ -0,0 +1,344 @@ +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; + private int _runSessionHandlerCount; + + 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(bool trackRunSessionHandler = false) + { + 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; + }; + + 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(); + + 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 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() + { + 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; });