Follow-ups to #1610: de-draft 2026-07-28 terminology, renumber error codes, ProtocolVersion floor (#1671)

This commit is contained in:
Stephen Halter 2026-06-25 14:07:52 -07:00 • committed by GitHub
commit d6b1615988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 785 additions and 898 deletions

View file

@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici
### Multi Round-Trip Requests (MRTR)
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
[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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
> [!IMPORTANT]
> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.

View file

@ -33,10 +33,10 @@ MRTR is useful when:
## Opting in
MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers the draft revision by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support draft. Servers accept the draft automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy 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 a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out.
```csharp
// Client — the SDK prefers the 2026-07-28 draft (and therefore MRTR) by default.
// Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default.
var clientOptions = new McpClientOptions
{
Handlers = new McpClientHandlers
@ -47,7 +47,7 @@ var clientOptions = new McpClientOptions
};
```
Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (which is stateless-only under the draft revision) must use `InputRequiredException` rather than <xref:ModelContextProtocol.Server.McpServer.ElicitAsync*>, <xref:ModelContextProtocol.Server.McpServer.SampleAsync*>, or <xref:ModelContextProtocol.Server.McpServer.RequestRootsAsync*>. The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft.
Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (where Streamable HTTP no longer supports sessions) must use `InputRequiredException` rather than <xref:ModelContextProtocol.Server.McpServer.ElicitAsync*>, <xref:ModelContextProtocol.Server.McpServer.SampleAsync*>, or <xref:ModelContextProtocol.Server.McpServer.RequestRootsAsync*>. The legacy methods still work on stateful sessions — that's how stdio servers keep working on `2026-07-28` today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or `2026-07-28`.
Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below.
@ -281,6 +281,6 @@ The SDK supports `InputRequiredException` across two protocol revisions and two
`ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless).
Under the current protocol revision (`2025-06-18` 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 draft 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.
Under the current protocol revision (`2025-06-18` 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 runs statelessly whenever a client speaks the draft. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves draft requests sessionlessly and creates a legacy 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 runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy session only when an older client falls back to `initialize`.

View file

@ -106,7 +106,7 @@ server.RegisterNotificationHandler(
### Multi Round-Trip Requests (MRTR)
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
[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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
> [!IMPORTANT]
> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.

View file

@ -123,7 +123,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han
### Multi Round-Trip Requests (MRTR)
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
[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 <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
> [!IMPORTANT]
> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.

View file

@ -24,42 +24,41 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a
<!-- mlc-disable-next-line -->
> [!NOTE]
> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful for back-compat with the `2025-11-25` (and older) protocol revisions, which require the `Mcp-Session-Id` header. The `2026-07-28` draft revision removes that header (SEP-2567) and the `initialize` handshake (SEP-2575) entirely, so the SDK now defaults <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> 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 <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> 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).
## 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` draft revision and beyond. Stateless servers still respond to legacy 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), though MRTR requires the unratified `2026-07-28` draft and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default:
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 legacy 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), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` 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 honored. The `2026-07-28` draft 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 draft clients without falling back to legacy 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 = 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 honored. 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 legacy 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)). MRTR is only as available as the unratified `2026-07-28` draft, however, so keep a session if you need server-to-client requests against clients that don't speak the draft. Note that even with `Stateless = false`, draft requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision.
- **`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)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy revision.
<!-- mlc-disable-next-line -->
> [!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.
### The 2026-07-28 draft revision
### The 2026-07-28 protocol revision
The `2026-07-28` draft 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 = 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).
**Server side.** With `Stateless = true` (the default), the SDK already meets the draft on the wire. Any HTTP POST that arrives with the draft `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 are not mapped. Legacy 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 falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a sessionless draft request on a stateful server is refused with a `-32004 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A draft request that carries an `Mcp-Session-Id` is always rejected, since the draft revision has no session concept.
**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 are not mapped. Legacy 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 falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept.
**Stateful options marked obsolete.** Because the draft revision is unconditionally sessionless, the stateful-only knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients.
**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients.
**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it:
**Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it:
- **HTTP**: the client sends its first request with the draft `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32004` / `-32003` / `-32001` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint.
- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms the draft revision; a `-32004` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process.
- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint.
- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process.
The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect.
**Opting out of fallback.** Set <xref:ModelContextProtocol.Client.McpClientOptions.MinProtocolVersion> to <xref:ModelContextProtocol.McpSession.DraftProtocolVersion> when you want the client to refuse to fall back. The connect call throws an <xref:ModelContextProtocol.McpException> instead of silently degrading. This is useful for strict-modern production code and for tests that need to assert draft-only behavior.
**Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an <xref:ModelContextProtocol.McpException> instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value.
```csharp
var clientOptions = new McpClientOptions
{
ProtocolVersion = McpSession.DraftProtocolVersion,
MinProtocolVersion = McpSession.DraftProtocolVersion,
ProtocolVersion = "2026-07-28",
};
```
@ -118,7 +117,7 @@ When <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless>
- [Roots](xref:roots) (`RequestRootsAsync`)
- Ping — the server cannot ping the client to verify connectivity
[MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak the `2026-07-28` draft — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions).
[MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak `2026-07-28` — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions).
- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why.
- **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client.
- **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means.
@ -140,9 +139,9 @@ Most MCP servers fall into this category. Tools that call APIs, query databases,
### Stateless alternatives for server-to-client interactions
The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached.
The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a stateless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached.
This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` draft, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak the draft.
This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` revision, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak it.
## Stateful mode (sessions)
@ -175,7 +174,7 @@ The [deployment considerations](#deployment-considerations) below are real conce
| **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing |
| **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize |
| **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) |
| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (draft-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) |
| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (`2026-07-28`-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) |
| **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) |
| **Resource subscriptions** | Not supported | Supported |
| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) |
@ -417,16 +416,16 @@ builder.Services.AddMcpServer()
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> | `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` draft revision. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> | `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. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> | `Func<HttpContext, McpServerOptions, CancellationToken, Task>?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all draft-revision requests), this runs on every HTTP request. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> | `Func<HttpContext, McpServerOptions, CancellationToken, Task>?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.RunSessionHandler> | `Func<HttpContext, McpServer, CancellationToken, Task>?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.SessionMigrationHandler> | `ISessionMigrationHandler?` | `null` | _Stateful only (`MCP9006`)._ Enables cross-instance session migration. Can also be registered in DI. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EventStreamStore> | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.PerSessionExecutionContext> | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal<T>` values but prevents `IHttpContextAccessor` from working in handlers. |
The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served sessionlessly (every draft-revision 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 `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path.
### ConfigureSessionOptions

View file

@ -44,4 +44,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the
| `MCP9003` | In place | The `RequestContext<TParams>(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext<TParams>(McpServer, JsonRpcRequest, TParams)`. |
| `MCP9004` | In place | <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — 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 for more information. |
| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. The draft protocol revision (`2026-07-28`) is sessionless, 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 <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `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. |

8
package-lock.json generated
View file

@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"@modelcontextprotocol/conformance": "0.2.0-alpha.2",
"@modelcontextprotocol/conformance": "0.2.0-alpha.5",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
@ -23,9 +23,9 @@
}
},
"node_modules/@modelcontextprotocol/conformance": {
"version": "0.2.0-alpha.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.2.tgz",
"integrity": "sha512-/8bde9d0mfsvgd9IwQgNIl1AS9uNOp/+ZG+2nNRWXtPs6xrz/cNp4ObBMmGY9kP8dkDaF3bvjtC/2Hj8TStMRg==",
"version": "0.2.0-alpha.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz",
"integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",

View file

@ -2,7 +2,7 @@
"private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
"@modelcontextprotocol/conformance": "0.2.0-alpha.2",
"@modelcontextprotocol/conformance": "0.2.0-alpha.5",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}

View file

@ -10,27 +10,23 @@ namespace ModelContextProtocol.Protocol;
internal static class McpHttpHeaders
{
/// <summary>
/// The draft MCP protocol version string used to gate behaviors that are only enabled
/// for clients negotiating the in-progress draft specification.
/// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the <c>initialize</c>
/// handshake and <c>Mcp-Session-Id</c>, so Streamable HTTP no longer has sessions; it also enabled
/// MRTR (SEP-2322) and made the standard MCP request headers (<c>Mcp-Method</c>, <c>Mcp-Name</c>)
/// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request
/// version against it (see <see cref="IsJuly2026OrLaterProtocolVersion"/>), so it underpins the more
/// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it
/// by default.
/// </summary>
/// <remarks>
/// Behaviors currently gated on this version include:
/// <list type="bullet">
/// <item><description>
/// Requiring the standard MCP request headers (<c>Mcp-Method</c> and <c>Mcp-Name</c>)
/// on Streamable HTTP POST requests; servers treat missing headers as errors only when
/// the client's <c>MCP-Protocol-Version</c> header matches this value.
/// </description></item>
/// <item><description>
/// Reporting unresolvable resource URIs from <c>resources/read</c> with the standard
/// JSON-RPC <see cref="McpErrorCode.InvalidParams"/> (-32602) code rather than the
/// legacy <see cref="McpErrorCode.ResourceNotFound"/> (-32002) code.
/// </description></item>
/// </list>
/// The associated helpers perform exact ordinal matches against this single value rather
/// than any ordered comparison.
/// </remarks>
public const string DraftProtocolVersion = "2026-07-28";
public const string July2026ProtocolVersion = "2026-07-28";
/// <summary>
/// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP
/// sessions (the <c>initialize</c> handshake and <c>Mcp-Session-Id</c>); newer revisions remove them.
/// It is the default version for the legacy <c>initialize</c> and session-resume code paths, and the
/// version the server advertises when a peer requests an unsupported version on the legacy handshake.
/// </summary>
public const string November2025ProtocolVersion = "2025-11-25";
/// <summary>The session identifier header.</summary>
public const string SessionId = "Mcp-Session-Id";
@ -76,18 +72,20 @@ internal static class McpHttpHeaders
internal const string ToolContextKey = "Mcp.Tool";
/// <summary>
/// Protocol versions that require standard MCP request headers (Mcp-Method, Mcp-Name).
/// Returns <see langword="true"/> if the given protocol version is <see cref="July2026ProtocolVersion"/>
/// or later, the revision that removed the <c>initialize</c> handshake and Streamable HTTP sessions.
/// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically.
/// </summary>
private static readonly HashSet<string> s_versionsWithStandardHeaders = new(StringComparer.Ordinal)
{
DraftProtocolVersion,
};
internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion)
=> !string.IsNullOrEmpty(protocolVersion)
&& StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0;
/// <summary>
/// Returns <see langword="true"/> if the given protocol version requires standard MCP request headers.
/// Returns <see langword="true"/> if the given protocol version requires standard MCP request headers
/// (<c>Mcp-Method</c>, <c>Mcp-Name</c>).
/// </summary>
public static bool SupportsStandardHeaders(string? protocolVersion)
=> !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!);
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the negotiated protocol version reports unresolvable
@ -95,5 +93,5 @@ internal static class McpHttpHeaders
/// rather than the legacy <see cref="McpErrorCode.ResourceNotFound"/> (-32002).
/// </summary>
internal static bool UseInvalidParamsForMissingResource(string? protocolVersion)
=> string.Equals(protocolVersion, DraftProtocolVersion, StringComparison.Ordinal);
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
}

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
@ -51,7 +51,7 @@ public class HttpServerTransportOptions
/// </summary>
/// <value>
/// <see langword="true"/> if the server runs in a stateless mode; <see langword="false"/> if the server tracks state between requests.
/// The default is <see langword="true"/> as of the <c>2026-07-28</c> draft protocol revision (SEP-2567);
/// The default is <see langword="true"/> as of the <c>2026-07-28</c> protocol revision (SEP-2567);
/// set to <see langword="false"/> only when you need to support legacy clients that rely on session affinity.
/// </value>
/// <remarks>
@ -61,12 +61,13 @@ public class HttpServerTransportOptions
/// 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.
/// <para>
/// The <c>2026-07-28</c> draft protocol revision is sessionless and removes <c>Mcp-Session-Id</c> entirely
/// (SEP-2567), so over HTTP draft requests are only ever served when <see langword="true"/>. When this
/// property is <see langword="false"/>, a sessionless draft request is refused with a
/// <c>-32004 UnsupportedProtocolVersion</c> error so that a dual-era client downgrades to the legacy
/// <c>initialize</c> handshake and obtains the session that the server was configured to provide. A draft
/// request that carries an <c>Mcp-Session-Id</c> is always rejected, regardless of this property's value.
/// Starting with the <c>2026-07-28</c> protocol revision, Streamable HTTP no longer supports sessions:
/// the revision removed <c>Mcp-Session-Id</c> (SEP-2567), so over HTTP its requests are only ever served
/// when this property is <see langword="true"/>. When it is <see langword="false"/>, such a request is
/// refused with a <c>-32022 UnsupportedProtocolVersion</c> error so that a dual-era client downgrades to
/// the legacy <c>initialize</c> handshake and obtains the session the server was configured to provide.
/// A request that carries an <c>Mcp-Session-Id</c> is always rejected by the <c>2026-07-28</c> and later
/// revisions, regardless of this property's value.
/// </para>
/// </remarks>
public bool Stateless { get; set; } = true;

View file

@ -39,17 +39,17 @@ internal sealed class StreamableHttpHandler(
"2024-11-05",
"2025-03-26",
"2025-06-18",
"2025-11-25",
McpHttpHeaders.DraftProtocolVersion,
McpHttpHeaders.November2025ProtocolVersion,
McpHttpHeaders.July2026ProtocolVersion,
];
/// <summary>
/// The supported protocol versions excluding the draft revision. Used when refusing a sessionless
/// draft request on a stateful (Stateless = false) server so a dual-era client falls back to a
/// legacy initialize handshake instead of retrying the draft version.
/// 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-era
/// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version.
/// </summary>
private static readonly string[] s_supportedProtocolVersionsExcludingDraft =
[.. s_supportedProtocolVersions.Where(static v => !string.Equals(v, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal))];
private static readonly string[] s_sessionSupportingProtocolVersions =
[.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))];
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
@ -140,14 +140,13 @@ internal sealed class StreamableHttpHandler(
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
// Under the draft protocol revision the standalone HTTP GET endpoint for unsolicited
// server-to-client messages is removed (SEP-2575); clients use subscriptions/listen (POST)
// instead. The draft is also sessionless (SEP-2567), so a draft GET is invalid whether or
// not it carries an Mcp-Session-Id.
if (IsDraftProtocolRequest(context))
// The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited
// server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP
// no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id.
if (IsJuly2026OrLaterProtocol(context))
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead.",
"Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.",
StatusCodes.Status400BadRequest);
return;
}
@ -258,12 +257,12 @@ internal sealed class StreamableHttpHandler(
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
// Under the draft revision there are no sessions to terminate (SEP-2567). A draft DELETE is
// invalid whether or not it carries an Mcp-Session-Id.
if (IsDraftProtocolRequest(context))
// Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567),
// so the DELETE is invalid whether or not it carries an Mcp-Session-Id.
if (IsJuly2026OrLaterProtocol(context))
{
await WriteJsonRpcErrorAsync(context,
"Bad Request: The DELETE endpoint is not supported by the draft protocol revision (the draft protocol is sessionless).",
"Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.",
StatusCodes.Status400BadRequest);
return;
}
@ -371,33 +370,31 @@ internal sealed class StreamableHttpHandler(
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message)
{
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
bool isDraftRequest = IsDraftProtocolRequest(context);
// Under the draft protocol revision, the draft is sessionless: SEP-2567 removes the
// Mcp-Session-Id header (and the session concept) and SEP-2575 removes the initialize
// handshake. So over HTTP, draft <=> sessionless, with no exceptions:
if (isDraftRequest)
// The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567)
// and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions:
if (IsJuly2026OrLaterProtocol(context))
{
if (!string.IsNullOrEmpty(sessionId))
{
// A draft request carrying an Mcp-Session-Id is non-conformant (SEP-2567).
// A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567).
await WriteJsonRpcErrorAsync(context,
"Bad Request: Mcp-Session-Id is not supported under the draft protocol revision; the draft protocol is sessionless (SEP-2567).",
"Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).",
StatusCodes.Status400BadRequest);
return null;
}
if (HttpServerTransportOptions.Stateless)
if (!HttpServerTransportOptions.Stateless)
{
// The default (stateless) HTTP transport serves sessionless draft requests natively.
return await StartNewSessionAsync(context);
// The author explicitly opted into sessions (Stateless = false), which the 2026-07-28
// revision cannot provide. Refuse it so a dual-era client falls back to the legacy
// initialize handshake and gets the session it asked for (SEP-2575 fallback semantics).
await WriteUnsupportedProtocolVersionErrorAsync(context);
return null;
}
// The author explicitly opted into sessions (Stateless = false), which the draft revision
// cannot provide. Refuse the draft version so a dual-era client falls back to the legacy
// initialize handshake and gets the session it asked for (SEP-2575 fallback semantics).
await WriteUnsupportedDraftVersionErrorAsync(context);
return null;
// The default (stateless) HTTP transport serves these requests natively.
return await StartNewSessionAsync(context);
}
if (string.IsNullOrEmpty(sessionId))
@ -431,14 +428,15 @@ internal sealed class StreamableHttpHandler(
}
/// <summary>
/// Returns <see langword="true"/> when the request declares the draft protocol revision via
/// the <c>MCP-Protocol-Version</c> header. Draft requests are always sessionless and do not perform
/// the legacy <c>initialize</c> handshake (SEP-2575 + SEP-2567).
/// Returns <see langword="true"/> when the request's <c>MCP-Protocol-Version</c> header declares a
/// revision that operates without sessions, so the server must serve it statelessly. Such requests
/// never carry an Mcp-Session-Id and never perform the legacy <c>initialize</c> handshake
/// (SEP-2575 + SEP-2567).
/// </summary>
private static bool IsDraftProtocolRequest(HttpContext context)
private static bool IsJuly2026OrLaterProtocol(HttpContext context)
{
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
return string.Equals(protocolVersionHeader, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal);
return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader);
}
private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context)
@ -446,9 +444,7 @@ internal sealed class StreamableHttpHandler(
string sessionId;
StreamableHttpServerTransport transport;
bool isStateless = HttpServerTransportOptions.Stateless;
if (!isStateless)
if (!HttpServerTransportOptions.Stateless)
{
sessionId = MakeNewSessionId();
#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally.
@ -467,7 +463,7 @@ internal sealed class StreamableHttpHandler(
}
else
{
// In stateless mode (legacy or draft), each request is independent. Don't set any session ID on the transport.
// In stateless mode, each request is independent. Don't set any session ID on the transport.
// If in the future we support resuming stateless requests, we should populate
// the event stream store and retry interval here as well.
sessionId = "";
@ -488,13 +484,12 @@ internal sealed class StreamableHttpHandler(
{
var mcpServerServices = applicationServices;
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
bool effectivelyStateless = HttpServerTransportOptions.Stateless;
if (effectivelyStateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null)
if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null)
{
mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName);
if (effectivelyStateless)
if (HttpServerTransportOptions.Stateless)
{
// The session does not outlive the request in stateless mode.
mcpServerServices = context.RequestServices;
@ -690,23 +685,25 @@ internal sealed class StreamableHttpHandler(
}
/// <summary>
/// Refuses a sessionless draft request on a stateful (Stateless = false) server. The draft revision
/// is sessionless (SEP-2567) and cannot honor the author's opt-in to sessions, so we return
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> with a supported-versions list that excludes
/// the draft. A dual-era client then falls back to the legacy initialize handshake (SEP-2575).
/// 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 <see cref="McpErrorCode.UnsupportedProtocolVersion"/> with a supported-versions list
/// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake
/// (SEP-2575).
/// </summary>
private static Task WriteUnsupportedDraftVersionErrorAsync(HttpContext context)
private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context)
{
var requestedProtocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
var errorDetail = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.UnsupportedProtocolVersion,
Message = $"Bad Request: The draft protocol revision '{McpHttpHeaders.DraftProtocolVersion}' is sessionless and is not supported when the server is configured with sessions (HttpServerTransportOptions.Stateless = false). " +
"Use the initialize handshake with a supported non-draft protocol version instead.",
Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.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.",
Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData
{
Supported = s_supportedProtocolVersionsExcludingDraft,
Requested = McpHttpHeaders.DraftProtocolVersion,
Supported = s_sessionSupportingProtocolVersions,
Requested = requestedProtocolVersion,
},
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
};

View file

@ -76,9 +76,9 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
}
else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
{
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server
// — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion,
// -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server.
// It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion,
// -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other
// application-level error). Don't fall back to SSE — that would mask the real signal
// and surface a misleading "session id required" error from the SSE GET path.
// Adopt the Streamable HTTP transport and throw the structured exception so the

View file

@ -1174,10 +1174,10 @@ public abstract partial class McpClient : McpSession
{
Name = requestParams.Name,
Arguments = requestParams.Arguments,
// The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call
// The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call
// (no task capability envelope) so the server returns a direct CallToolResult and never
// creates a task.
Meta = IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
};
JsonRpcRequest jsonRpcRequest = new()
@ -1405,19 +1405,18 @@ public abstract partial class McpClient : McpSession
}
/// <summary>
/// Throws when the negotiated protocol version is not the draft revision. The SEP-2663 Tasks
/// extension is draft-only, and a task id only ever exists when the session negotiated draft, so
/// invoking <c>tasks/get</c>, <c>tasks/update</c>, or <c>tasks/cancel</c> on a legacy session is a
/// programming error rather than a recoverable protocol condition.
/// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks
/// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session
/// negotiated such a revision, so invoking <c>tasks/get</c>, <c>tasks/update</c>, or <c>tasks/cancel</c>
/// on an older session is a programming error rather than a recoverable protocol condition.
/// </summary>
private void ThrowIfTasksNotSupported(string operationName)
{
if (!IsDraftProtocol())
if (!IsJuly2026OrLaterProtocol())
{
throw new InvalidOperationException(
$"'{operationName}' requires the draft protocol revision ('{DraftProtocolVersion}'). " +
$"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'. " +
"The Tasks extension is only available under the draft revision.");
$"'{operationName}' requires a newer protocol revision that supports tasks. " +
$"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.");
}
}
}

View file

@ -289,21 +289,21 @@ internal sealed partial class McpClientImpl : McpClient
try
{
// The draft protocol revision (SEP-2575) is the default: there is no initialize
// The 2026-07-28 revision (SEP-2575) is the default: there is no initialize
// handshake. Instead, the client calls server/discover to learn the server's
// capabilities and then begins sending normal RPCs that carry protocolVersion /
// clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion
// prefers the draft revision and automatically falls back to the legacy initialize
// handshake when the server doesn't support it. The legacy branch below runs only
// when the caller explicitly pins a non-draft version (opting out of draft).
if (_options.ProtocolVersion is null || _options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion)
// prefers the 2026-07-28 revision and automatically falls back to the legacy initialize
// handshake when the server doesn't support it. The legacy branch below runs only when
// the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default).
if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion))
{
string draftVersion = McpSessionHandler.DraftProtocolVersion;
string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion;
// Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as
// a draft client when SendRequestAsync is invoked for server/discover.
_negotiatedProtocolVersion = draftVersion;
_sessionHandler.NegotiatedProtocolVersion = draftVersion;
// Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being
// on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover.
_negotiatedProtocolVersion = preferredVersion;
_sessionHandler.NegotiatedProtocolVersion = preferredVersion;
DiscoverResult? discoverResult = null;
bool fallbackToLegacy = false;
@ -331,7 +331,7 @@ internal sealed partial class McpClientImpl : McpClient
}
catch (UnsupportedProtocolVersionException ex)
{
// Spec-recognized modern-server signal: -32004 with data.supported[]. The server is
// Spec-recognized modern-server signal: -32022 with data.supported[]. The server is
// modern but doesn't speak our preferred version. Retry with a mutually supported
// version from data.supported[] instead of falling back to legacy initialize.
fallbackToLegacy = true;
@ -339,13 +339,13 @@ internal sealed partial class McpClientImpl : McpClient
}
catch (MissingRequiredClientCapabilityException)
{
// Spec-recognized modern-server signal: -32003. The server is modern but rejected
// Spec-recognized modern-server signal: -32021. The server is modern but rejected
// our capability set. Surface as-is (no fallback): the user must add capabilities.
throw;
}
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch)
{
// Spec-recognized modern-server signal: -32001. The server is modern but rejected
// Spec-recognized modern-server signal: -32020. The server is modern but rejected
// our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match
// the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no
// fallback): falling back to legacy initialize wouldn't fix a malformed envelope.
@ -353,14 +353,14 @@ internal sealed partial class McpClientImpl : McpClient
}
catch (McpProtocolException)
{
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code —
// any non-modern JSON-RPC error from the probe indicates a legacy server.
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code.
// Any non-modern JSON-RPC error from the probe indicates a legacy server.
// Common causes include MethodNotFound from a server that has no
// server/discover handler, InvalidParams from a server confused by the
// SEP-2575 _meta envelope, ParseError from a server that can't handle our
// payload shape, or any other transport-defined error. The three modern-server
// signals (-32004 UnsupportedProtocolVersion, -32003
// MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and
// signals (-32022 UnsupportedProtocolVersion, -32021
// MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and
// never reach here.
fallbackToLegacy = true;
}
@ -371,10 +371,10 @@ internal sealed partial class McpClientImpl : McpClient
fallbackToLegacy = true;
}
if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion))
if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion))
{
// Server is reachable and supports server/discover, but doesn't support the
// experimental version. Fall back to legacy initialize with the highest
// 2026-07-28 version. Fall back to legacy initialize with the highest
// mutually-supported version from supportedVersions[].
fallbackToLegacy = true;
serverSupportedVersions = discoverResult.SupportedVersions;
@ -390,15 +390,17 @@ internal sealed partial class McpClientImpl : McpClient
.Where(McpSessionHandler.SupportedProtocolVersions.Contains)
.OrderByDescending(v => v, StringComparer.Ordinal)
.FirstOrDefault()
?? McpSessionHandler.LatestProtocolVersion;
?? McpHttpHeaders.November2025ProtocolVersion;
// Honor MinProtocolVersion: refuse to fall back below the configured minimum.
// String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions.
if (_options.MinProtocolVersion is { } minVersion &&
StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0)
// A non-null ProtocolVersion is also the minimum: refuse to fall back below the
// explicitly requested version. String.Compare is the spec's prescribed ordering
// for ISO-8601 date-based versions.
if (_options.ProtocolVersion is { } pinnedVersion &&
StringComparer.Ordinal.Compare(fallbackVersion, pinnedVersion) < 0)
{
throw new McpException(
$"Server does not support the configured minimum protocol version '{minVersion}'. " +
$"The server does not support the requested protocol version '{pinnedVersion}'. " +
"Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " +
(serverSupportedVersions is null
? "The server appears to be a legacy server that requires the deprecated initialize handshake."
: $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."));
@ -423,9 +425,9 @@ internal sealed partial class McpClientImpl : McpClient
else
{
// Legacy initialize handshake. Reached only when the caller explicitly pinned a
// non-draft ProtocolVersion (opting out of the draft default), so
// ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so
// _options.ProtocolVersion is non-null here.
string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion;
await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false);
}
}
@ -475,13 +477,13 @@ internal sealed partial class McpClientImpl : McpClient
_serverInfo = initializeResponse.ServerInfo;
_serverInstructions = initializeResponse.Instructions;
// When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST
// respect it. When the user pinned the draft version but we fell back (e.g., legacy server
// rejected server/discover), or when no version was pinned, accept any supported response.
// This is the spec-mandated behavior: a draft client must be able to downgrade to whatever
// legacy version the server advertises.
// When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it.
// When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected
// server/discover), or when no version was pinned, accept any supported response. This is the
// spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever
// version the server advertises.
bool isResponseProtocolValid;
if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion)
if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol))
{
isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion;
}
@ -495,15 +497,6 @@ internal sealed partial class McpClientImpl : McpClient
throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}");
}
// If the user set a MinProtocolVersion, also enforce it against the negotiated response
// (the server could have downgraded further than the version we asked for).
if (_options.MinProtocolVersion is { } minVersion &&
StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0)
{
throw new McpException(
$"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'.");
}
_negotiatedProtocolVersion = initializeResponse.ProtocolVersion;
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
@ -531,7 +524,7 @@ internal sealed partial class McpClientImpl : McpClient
_serverInstructions = resumeOptions.ServerInstructions;
_negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion
?? _options.ProtocolVersion
?? McpSessionHandler.LatestProtocolVersion;
?? McpHttpHeaders.November2025ProtocolVersion;
// Update session handler with the negotiated protocol version for telemetry
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
@ -626,7 +619,7 @@ internal sealed partial class McpClientImpl : McpClient
const int maxRetries = 10;
InjectDraftMetaIfNeeded(request);
InjectRequestMetaIfNeeded(request);
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
@ -665,7 +658,7 @@ internal sealed partial class McpClientImpl : McpClient
}
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
InjectDraftMetaIfNeeded(request);
InjectRequestMetaIfNeeded(request);
}
else if (inputRequiredResult.RequestState is not null)
{
@ -675,7 +668,7 @@ internal sealed partial class McpClientImpl : McpClient
paramsObj.Remove("inputResponses");
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
InjectDraftMetaIfNeeded(request);
InjectRequestMetaIfNeeded(request);
}
else
{
@ -695,25 +688,25 @@ internal sealed partial class McpClientImpl : McpClient
}
/// <summary>
/// Injects the draft-protocol per-request <c>_meta</c> fields (protocol version, client info,
/// client capabilities) into the request when this client is using the draft protocol revision
/// (SEP-2575). No-op for legacy clients.
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields (protocol version, client info,
/// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision
/// (SEP-2575). No-op on a legacy session.
/// </summary>
private void InjectDraftMetaIfNeeded(JsonRpcRequest request)
private void InjectRequestMetaIfNeeded(JsonRpcRequest request)
{
if (!IsDraftProtocol())
if (!IsJuly2026OrLaterProtocol())
{
return;
}
// Initialize is never sent under the draft revision, but guard defensively in case a caller
// Initialize is never sent on a 2026-07-28 session, but guard defensively in case a caller
// routes it through here (e.g., during back-compat fallback negotiation).
if (request.Method == RequestMethods.Initialize)
{
return;
}
McpSessionHandler.InjectDraftMeta(
McpSessionHandler.InjectRequestMeta(
request,
_negotiatedProtocolVersion!,
_options.ClientInfo ?? DefaultImplementation,
@ -755,7 +748,7 @@ internal sealed partial class McpClientImpl : McpClient
/// <summary>Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request.</summary>
private void WarnIfLegacyRequestOnMrtrSession(string method)
{
if (IsDraftProtocol())
if (IsJuly2026OrLaterProtocol())
{
LogLegacyRequestOnMrtrSession(_endpointName, method);
}
@ -764,7 +757,7 @@ internal sealed partial class McpClientImpl : McpClient
/// <summary>Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult.</summary>
private void WarnIfInputRequiredResultOnNonMrtrSession(string method)
{
if (!IsDraftProtocol())
if (!IsJuly2026OrLaterProtocol())
{
LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion);
}

View file

@ -52,52 +52,23 @@ public sealed class McpClientOptions
/// </summary>
/// <remarks>
/// <para>
/// When non-<see langword="null"/>, this version is requested from the server. Setting it to a
/// legacy (non-draft) version such as <c>2025-11-25</c> opts out of the draft revision and forces
/// the <c>initialize</c> handshake; the handshake then fails if the server's negotiated version
/// does not match.
/// When <see langword="null"/> (the default), the client prefers the latest revision (<c>2026-07-28</c>),
/// which removed the <c>initialize</c> handshake and Streamable HTTP sessions. It probes with
/// <c>server/discover</c> and automatically falls back to the legacy <c>initialize</c> handshake,
/// downgrading to any version the server advertises, when the server does not support that revision.
/// </para>
/// <para>
/// When <see langword="null"/> (the default), the client prefers the draft revision
/// (<see cref="McpSession.DraftProtocolVersion"/>): it probes with <c>server/discover</c> and
/// automatically falls back to a legacy <c>initialize</c> handshake, downgrading to any version
/// the server advertises, when the server does not support the draft revision.
/// When non-<see langword="null"/>, this value is both the requested version and the minimum the client
/// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an
/// <see cref="McpException"/> instead of falling back. Setting it to <c>2026-07-28</c> therefore disables
/// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP
/// sessions, such as <c>2025-11-25</c>, forces the <c>initialize</c> handshake and fails if the server
/// negotiates a different version. To try more than one version, leave this unset for automatic fallback
/// or retry the connection with a different value.
/// </para>
/// </remarks>
public string? ProtocolVersion { get; set; }
/// <summary>
/// Gets or sets the minimum protocol version the client will accept during version negotiation.
/// </summary>
/// <remarks>
/// <para>
/// When negotiating with a server that advertises multiple supported versions, or when falling back
/// to a legacy server, the client will refuse any version older than this minimum and surface an
/// <see cref="McpException"/> instead.
/// </para>
/// <para>
/// This is useful when the client requires features (such as the draft revision's removal of the
/// <c>initialize</c> handshake or <c>Mcp-Session-Id</c>) that are not available in older protocol
/// revisions. Because the client already prefers the draft revision by default, setting this to
/// <see cref="McpSession.DraftProtocolVersion"/> disables the automatic legacy-server fallback
/// that otherwise switches to the <c>initialize</c> handshake.
/// </para>
/// <para>
/// If <see langword="null"/> (the default), the client falls back to any version the server
/// advertises, including legacy versions such as 2025-11-25.
/// </para>
/// <example>
/// <code>
/// // The draft revision is already the default; pin the minimum to refuse the legacy fallback.
/// var clientOptions = new McpClientOptions
/// {
/// MinProtocolVersion = McpSession.DraftProtocolVersion,
/// };
/// </code>
/// </example>
/// </remarks>
public string? MinProtocolVersion { get; set; }
/// <summary>
/// Gets or sets a timeout for the client-server initialization handshake sequence.
/// </summary>
@ -128,10 +99,10 @@ public sealed class McpClientOptions
/// </value>
/// <remarks>
/// <para>
/// This timeout only has an effect when the client prefers the draft protocol revision &#8212; that is,
/// when <see cref="ProtocolVersion"/> is <see langword="null"/> (the default) or
/// <see cref="McpSession.DraftProtocolVersion"/>. In that mode the client first probes the server
/// with a <c>server/discover</c> request. A legacy server that predates the draft revision may
/// This timeout only has an effect when the client prefers the <c>2026-07-28</c> protocol revision, that is,
/// when <see cref="ProtocolVersion"/> is <see langword="null"/> (the default) or <c>2026-07-28</c>.
/// In that mode the client first probes the server with a
/// <c>server/discover</c> request. A legacy server that predates the <c>2026-07-28</c> revision may
/// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the
/// client concludes the server is legacy and falls back to the <c>initialize</c> handshake on the
/// same connection. When the caller pins a legacy <see cref="ProtocolVersion"/>, no probe is issued
@ -140,7 +111,7 @@ public sealed class McpClientOptions
/// <para>
/// The default is intentionally short so that dual-era clients fall back quickly against legacy
/// servers. Increase it for high-latency environments (for example, cold-start serverless peers or
/// satellite links) where a short probe could trigger the legacy fallback before a draft-capable
/// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision
/// server has had a chance to respond. The probe is always also bounded by
/// <see cref="InitializationTimeout"/>, which governs the overall connect budget: if this value is
/// greater than or equal to <see cref="InitializationTimeout"/>, the probe is effectively bounded by

View file

@ -67,19 +67,19 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
// Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a
// JSON-RPC error envelope means the peer is signalling something application-level about
// our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the
// connect-time logic can react — for example, the three modern draft-protocol error codes
// (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability,
// -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from
// legacy servers that don't understand the draft _meta envelope) become generic
// connect-time logic can react. For example, the three modern protocol error codes
// (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability,
// -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from
// legacy servers that don't understand the SEP-2575 _meta envelope) become generic
// McpProtocolException instances and trigger the fallback-to-legacy-initialize path.
// Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue
// to surface as HttpRequestException to preserve back-compat with transport-layer behaviors.
// The three modern draft-protocol error codes are also surfaced for non-400 status codes
// for robustness — servers occasionally emit them with 4xx codes other than 400.
// The three modern protocol error codes are also surfaced for non-400 status codes
// for robustness. Servers occasionally emit them with 4xx codes other than 400.
if (!response.IsSuccessStatusCode &&
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
(response.StatusCode == HttpStatusCode.BadRequest ||
IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code)))
IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
{
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
}
@ -87,7 +87,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
}
private static bool IsModernDraftErrorCode(McpErrorCode code) =>
private static bool IsModernProtocolErrorCode(McpErrorCode code) =>
code is McpErrorCode.UnsupportedProtocolVersion
or McpErrorCode.MissingRequiredClientCapability
or McpErrorCode.HeaderMismatch;
@ -144,9 +144,9 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
LogTransportSendingMessageSensitive(message);
// Under the draft protocol revision (SEP-2575), every request carries its protocol version in
// Under the 2026-07-28 or later protocol revision (SEP-2575), every request carries its protocol version in
// _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP
// header). Pick the value off the message so the first draft request (server/discover) can
// header). Pick the value off the message so the first request (server/discover) can
// include the header even before we've recorded a negotiated version from an initialize reply.
var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion;
@ -229,7 +229,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
{
// Under the draft protocol revision (SEP-2575), server/discover replaces the initialize
// Under the 2026-07-28 or later protocol revision (SEP-2575), server/discover replaces the initialize
// handshake. The transport caches the protocol version from the outgoing request's _meta
// so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing.
_negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message);
@ -240,7 +240,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
/// <summary>
/// Reads the protocol version from a request's <c>_meta/io.modelcontextprotocol/protocolVersion</c> field,
/// introduced by the draft protocol revision (SEP-2575). Returns <see langword="null"/> for messages that
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns <see langword="null"/> for messages that
/// don't have that field.
/// </summary>
private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message)

View file

@ -23,7 +23,7 @@ public enum McpErrorCode
/// This error code is in the JSON-RPC implementation-defined server error range (<c>-32000</c> to <c>-32099</c>).
/// </para>
/// </remarks>
HeaderMismatch = -32001,
HeaderMismatch = -32020,
/// <summary>
/// Indicates that the requested resource could not be found.
@ -49,24 +49,24 @@ public enum McpErrorCode
/// </summary>
/// <remarks>
/// <para>
/// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). The error data MUST include a
/// <c>requiredCapabilities</c> object describing the capabilities the server requires from the client
/// to process the request. For HTTP, the response status code is <c>400 Bad Request</c>.
/// </para>
/// </remarks>
MissingRequiredClientCapability = -32003,
MissingRequiredClientCapability = -32021,
/// <summary>
/// Indicates that the request's declared protocol version is not supported by the server.
/// </summary>
/// <remarks>
/// <para>
/// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). The error data MUST include a
/// <c>supported</c> array of protocol version strings the server supports and the original
/// <c>requested</c> protocol version. For HTTP, the response status code is <c>400 Bad Request</c>.
/// </para>
/// </remarks>
UnsupportedProtocolVersion = -32004,
UnsupportedProtocolVersion = -32022,
/// <summary>
/// Indicates that URL-mode elicitation is required to complete the requested operation.

View file

@ -89,7 +89,7 @@ public static partial class McpJsonUtilities
// matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form
// with keywords like "type", "properties", etc.) or a boolean (`true` matches anything,
// `false` matches nothing). Stricter keyword-level validation is intentionally not
// performed. Pre-2026-06-30 clients still receive the legacy wrapped wire shape — that
// performed. Pre-2026-07-28 clients still receive the legacy wrapped wire shape — that
// wiring lives in AIFunctionMcpServerTool.CreateStructuredResponse and McpServerImpl's
// listToolsHandler.
internal static bool IsValidToolOutputSchema(JsonElement element) =>

View file

@ -28,27 +28,6 @@ namespace ModelContextProtocol;
/// </remarks>
public abstract partial class McpSession : IAsyncDisposable
{
/// <summary>The latest stable protocol revision this SDK supports.</summary>
/// <remarks>
/// Set <see cref="McpClientOptions.ProtocolVersion"/> or <see cref="McpServerOptions.ProtocolVersion"/>
/// to this value to explicitly pin to the current stable revision instead of accepting whatever
/// the runtime negotiates.
/// </remarks>
public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion;
/// <summary>The in-progress draft protocol revision this SDK supports.</summary>
/// <remarks>
/// The draft revision removes the <c>initialize</c> handshake (SEP-2575) and the
/// <c>Mcp-Session-Id</c> header (SEP-2567), so it is sessionless on the wire and over HTTP is only
/// served when the server is stateless. A stateful (<c>HttpServerTransportOptions.Stateless = false</c>)
/// server refuses a sessionless draft request so that a dual-era client downgrades to the legacy
/// <c>initialize</c> flow. Clients prefer this revision by default and automatically fall back to the
/// legacy flow when the server does not support it; pin <see cref="McpClientOptions.ProtocolVersion"/>
/// to a legacy version to opt out, or set <see cref="McpClientOptions.MinProtocolVersion"/> to this
/// value to keep the draft preference while refusing the legacy fallback.
/// </remarks>
public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion;
/// <summary>Gets an identifier associated with the current MCP session.</summary>
/// <remarks>
/// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE.
@ -67,15 +46,16 @@ public abstract partial class McpSession : IAsyncDisposable
public abstract string? NegotiatedProtocolVersion { get; }
/// <summary>
/// Gets a value indicating whether the negotiated protocol version is the draft revision
/// (<see cref="DraftProtocolVersion"/>, which carries SEP-2575 + SEP-2567 + MRTR).
/// Gets a value indicating whether the negotiated protocol version is <c>2026-07-28</c> or later: the
/// revision that removed the <c>initialize</c> handshake (SEP-2575) and <c>Mcp-Session-Id</c> (SEP-2567)
/// and enabled MRTR (SEP-2322).
/// </summary>
/// <remarks>
/// Returns <see langword="false"/> when no version has been negotiated yet. This is the shared
/// definition of "is this peer speaking the draft revision" used by both the client and server.
/// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server.
/// </remarks>
internal bool IsDraftProtocol() =>
NegotiatedProtocolVersion == DraftProtocolVersion;
internal bool IsJuly2026OrLaterProtocol() =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion);
/// <summary>
/// Sends a JSON-RPC request to the connected session and waits for a response.

View file

@ -28,22 +28,9 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
private static readonly Histogram<double> s_serverOperationDuration = Diagnostics.CreateDurationHistogram(
"mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.");
/// <summary>The latest version of the protocol supported by this implementation.</summary>
internal const string LatestProtocolVersion = "2025-11-25";
/// <summary>
/// The draft protocol version (SEP-2575 + SEP-2567) that removes the <c>initialize</c> handshake
/// and <c>Mcp-Session-Id</c> and enables MRTR (Multi Round-Trip Requests) per SEP-2322.
/// Clients prefer this revision by default and fall back to the legacy <c>initialize</c> handshake
/// when the server does not support it; pin <see cref="McpClientOptions.ProtocolVersion"/> to a
/// legacy version to opt out. Servers remain reactive: with
/// <see cref="McpServerOptions.ProtocolVersion"/> left <see langword="null"/> they honor whichever
/// supported revision each peer requests, so a single server serves both draft and legacy clients.
/// </summary>
internal const string DraftProtocolVersion = "2026-07-28";
/// <summary>
/// All protocol versions supported by this implementation.
/// All protocol versions supported by this implementation. The version constants live on
/// <see cref="McpHttpHeaders"/> so the shared source file is the single source of truth.
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
/// </summary>
internal static readonly string[] SupportedProtocolVersions =
@ -51,8 +38,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"2024-11-05",
"2025-03-26",
"2025-06-18",
LatestProtocolVersion,
DraftProtocolVersion,
McpHttpHeaders.November2025ProtocolVersion,
McpHttpHeaders.July2026ProtocolVersion,
];
/// <summary>
@ -66,7 +53,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// </remarks>
internal static bool SupportsPrimingEvent(string? protocolVersion)
{
const string MinResumabilityProtocolVersion = "2025-11-25";
const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion;
if (protocolVersion is null)
{
@ -82,22 +69,19 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// </summary>
/// <param name="protocolVersion">The negotiated protocol version, or <c>null</c> if
/// negotiation has not completed.</param>
/// <returns><c>true</c> if the version is <c>"2026-06-30"</c> or later (including the
/// in-flight <c>"DRAFT-2026-06-v1"</c>, since <c>'D' &gt; '2'</c> ordinally); <c>false</c>
/// otherwise. A <c>false</c> return signals that the wire emission boundary must apply
/// the <c>{"result": &lt;value&gt;}</c> envelope expected by clients on protocol versions
/// that pre-date SEP-2106's widening of <c>outputSchema</c> to any JSON Schema 2020-12
/// document.</returns>
/// <returns><c>true</c> if the version is the <c>2026-07-28</c> revision or later, which is where
/// SEP-2106 widened <c>outputSchema</c> to any JSON Schema 2020-12 document; <c>false</c> otherwise.
/// A <c>false</c> return signals that the wire emission boundary must apply the
/// <c>{"result": &lt;value&gt;}</c> envelope expected by clients on protocol versions that pre-date
/// SEP-2106.</returns>
internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
{
const string MinNaturalOutputSchemasProtocolVersion = "2026-06-30";
if (protocolVersion is null)
{
return false;
}
return string.Compare(protocolVersion, MinNaturalOutputSchemasProtocolVersion, StringComparison.Ordinal) >= 0;
return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0;
}
private readonly bool _isServer;
@ -169,17 +153,16 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
_outgoingMessageFilter = outgoingMessageFilter ?? (next => next);
_logger = logger;
// ping was removed in the draft protocol revision (SEP-2575). Under draft, return
// MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult.
// Liveness on draft sessions belongs to transport- and request-level timeouts, not a
// dedicated MCP RPC.
// ping was removed in the 2026-07-28 protocol revision (SEP-2575). On the 2026-07-28 or later version,
// return MethodNotFound; on an older version, the per-spec behavior is to always answer
// with PingResult. Liveness on those requests belongs to transport- and request-level
// timeouts, not a dedicated MCP RPC.
_requestHandlers.Set(
RequestMethods.Ping,
(request, jsonRpcRequest, cancellationToken) =>
{
string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
if (perRequestVersion is not null &&
StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0)
if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion))
{
throw new McpProtocolException(
$"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.",
@ -426,7 +409,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
// Project the draft-protocol per-request _meta fields onto the message context before any
// Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any
// filters run so they (and downstream handlers) can read client info / capabilities /
// protocol version / log level without re-parsing.
if (_isServer && message is JsonRpcRequest incomingRequest)
@ -570,7 +553,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
}
/// <summary>
/// Reads the draft-protocol per-request <c>_meta</c> fields off the request and projects them onto
/// Reads the 2026-07-28 protocol's per-request <c>_meta</c> fields off the request and projects them onto
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
/// </summary>
/// <remarks>
@ -599,8 +582,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
{
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already
// populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
// -32001 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
// which conformant draft clients recognize as a modern-server signal and surface as-is rather
// -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
// which conformant 2026-07-28 clients recognize as a modern-server signal and surface as-is rather
// than mistaking it for a legacy server and falling back to the initialize handshake.
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
{
@ -629,16 +612,16 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
}
/// <summary>
/// Injects the draft-protocol per-request <c>_meta</c> fields into an outgoing request.
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields into an outgoing request.
/// Protocol version and client info overwrite any existing values; client capabilities are merged
/// so per-request capability opt-ins already present in the envelope are preserved.
/// </summary>
/// <remarks>
/// Used by <see cref="Client.McpClient"/> in draft mode to carry protocol version, client info, and
/// client capabilities on every outgoing request (replacing what the legacy <c>initialize</c> handshake
/// previously negotiated once).
/// Used by <see cref="Client.McpClient"/> on a 2026-07-28 or later session to carry protocol version, client
/// info, and client capabilities on every outgoing request (replacing what the legacy
/// <c>initialize</c> handshake previously negotiated once).
/// </remarks>
internal static void InjectDraftMeta(
internal static void InjectRequestMeta(
JsonRpcRequest request,
string protocolVersion,
Implementation clientInfo,

Some files were not shown because too many files have changed in this diff Show more