Tighten draft protocol negotiation boundaries (#1692)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
Stephen Halter 2026-07-12 14:30:49 -07:00 • committed by GitHub
commit fd38f51e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1755 additions and 589 deletions

View file

@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw <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] > [!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. > `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
For example: For example:

View file

@ -33,7 +33,7 @@ MRTR is useful when:
## Opting in ## Opting in
MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to 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. MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out.
```csharp ```csharp
// Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default.
@ -283,4 +283,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two
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. 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 `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`. Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`.

View file

@ -109,7 +109,7 @@ server.RegisterNotificationHandler(
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw <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] > [!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. > `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
For example: For example:

View file

@ -126,7 +126,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw <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] > [!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. > `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
For example: For example:

View file

@ -28,11 +28,11 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a
## Forward and backward compatibility ## Forward and backward compatibility
The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to 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: The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), 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` 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 = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today.
- **`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. - **`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 with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision.
<!-- mlc-disable-next-line --> <!-- mlc-disable-next-line -->
> [!TIP] > [!TIP]
@ -42,18 +42,18 @@ The `Stateless` property is the single most important setting for forward-proofi
The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). The `2026-07-28` protocol revision goes further than `Stateless = 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 `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. **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. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request.
**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. **Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients.
**Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: **Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it:
- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. - **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint.
- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. - **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process.
The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect. The era is cached per <xref:ModelContextProtocol.Client.IClientTransport> instance, so the probe cost is paid only on the first connect.
**Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an <xref:ModelContextProtocol.McpException> instead of silently degrading to 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. **Opting out of fallback.** Pin <xref:ModelContextProtocol.Client.McpClientOptions.ProtocolVersion> to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an <xref:ModelContextProtocol.McpException> instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value.
```csharp ```csharp
var clientOptions = new McpClientOptions var clientOptions = new McpClientOptions

View file

@ -183,7 +183,7 @@ app.MapMcp();
app.Run(); app.Run();
``` ```
By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown.
#### Host name validation #### Host name validation

View file

@ -9,25 +9,6 @@ namespace ModelContextProtocol.Protocol;
/// </remarks> /// </remarks>
internal static class McpHttpHeaders internal static class McpHttpHeaders
{ {
/// <summary>
/// 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>
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> /// <summary>The session identifier header.</summary>
public const string SessionId = "Mcp-Session-Id"; public const string SessionId = "Mcp-Session-Id";
@ -71,27 +52,4 @@ internal static class McpHttpHeaders
/// </summary> /// </summary>
internal const string ToolContextKey = "Mcp.Tool"; internal const string ToolContextKey = "Mcp.Tool";
/// <summary>
/// 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>
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
/// (<c>Mcp-Method</c>, <c>Mcp-Name</c>).
/// </summary>
public static bool SupportsStandardHeaders(string? protocolVersion)
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the negotiated protocol version reports unresolvable
/// resource URIs with the standard JSON-RPC <see cref="McpErrorCode.InvalidParams"/> (-32602)
/// rather than the legacy <see cref="McpErrorCode.ResourceNotFound"/> (-32002).
/// </summary>
internal static bool UseInvalidParamsForMissingResource(string? protocolVersion)
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
} }

View file

@ -0,0 +1,113 @@
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Internal helpers for MCP protocol revision strings and protocol-era behavior gates.
/// </summary>
internal static class McpProtocolVersions
{
/// <summary>
/// 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>
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 <c>initialize</c> and session-resume code paths, and the version
/// the server advertises when a peer requests an unsupported version on the initialize handshake.
/// </summary>
public const string November2025ProtocolVersion = "2025-11-25";
/// <summary>The 2025-06-18 MCP protocol revision.</summary>
public const string June2025ProtocolVersion = "2025-06-18";
/// <summary>The 2025-03-26 MCP protocol revision.</summary>
public const string March2025ProtocolVersion = "2025-03-26";
/// <summary>The 2024-11-05 MCP protocol revision.</summary>
public const string November2024ProtocolVersion = "2024-11-05";
/// <summary>
/// Protocol versions that still use the <c>initialize</c> handshake.
/// </summary>
internal static readonly string[] InitializeHandshakeProtocolVersions =
[
November2024ProtocolVersion,
March2025ProtocolVersion,
June2025ProtocolVersion,
November2025ProtocolVersion,
];
/// <summary>
/// Protocol versions that use per-request metadata instead of the <c>initialize</c> handshake.
/// </summary>
internal static readonly string[] PerRequestMetadataProtocolVersions =
[
July2026ProtocolVersion,
];
/// <summary>
/// All protocol versions supported by this implementation.
/// </summary>
internal static readonly string[] SupportedProtocolVersions =
[
.. InitializeHandshakeProtocolVersions,
.. PerRequestMetadataProtocolVersions,
];
/// <summary>
/// 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>
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 is supported by this implementation.
/// </summary>
internal static bool IsSupportedProtocolVersion(string? protocolVersion)
=> protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the given protocol version is available through the
/// <c>initialize</c> handshake.
/// </summary>
internal static bool SupportsInitializeHandshake(string? protocolVersion)
=> protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the given protocol version requires the handshake-free
/// per-request metadata path.
/// </summary>
internal static bool RequiresPerRequestMetadata(string? protocolVersion)
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the given protocol version requires standard MCP request headers
/// (<c>Mcp-Method</c>, <c>Mcp-Name</c>).
/// </summary>
internal static bool RequiresStandardHeaders(string? protocolVersion)
=> RequiresPerRequestMetadata(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the given protocol version supports Streamable HTTP sessions.
/// </summary>
internal static bool SupportsHttpSessions(string? protocolVersion)
=> !RequiresPerRequestMetadata(protocolVersion);
/// <summary>
/// Returns <see langword="true"/> if the negotiated protocol version reports unresolvable
/// resource URIs with the standard JSON-RPC <see cref="McpErrorCode.InvalidParams"/> (-32602)
/// rather than the legacy <see cref="McpErrorCode.ResourceNotFound"/> (-32002).
/// </summary>
internal static bool UseInvalidParamsForMissingResource(string? protocolVersion)
=> IsJuly2026OrLaterProtocolVersion(protocolVersion);
}

View file

@ -64,10 +64,10 @@ public class HttpServerTransportOptions
/// Starting with the <c>2026-07-28</c> protocol revision, Streamable HTTP no longer supports sessions: /// 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 /// 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 /// 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 /// refused with a <c>-32022 UnsupportedProtocolVersion</c> error so that a dual-path client downgrades to
/// the legacy <c>initialize</c> handshake and obtains the session the server was configured to provide. /// the <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 /// A request that carries an <c>Mcp-Session-Id</c> on the <c>2026-07-28</c> and later revisions is ignored;
/// revisions, regardless of this property's value. /// the server must not mint or echo session IDs for those revisions.
/// </para> /// </para>
/// </remarks> /// </remarks>
public bool Stateless { get; set; } = true; public bool Stateless { get; set; } = true;

View file

@ -25,6 +25,7 @@
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" /> <Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" /> <Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" /> <Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
<Compile Include="..\Common\McpProtocolVersions.cs" Link="McpProtocolVersions.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -13,6 +13,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore; namespace ModelContextProtocol.AspNetCore;
@ -32,24 +33,15 @@ internal sealed class StreamableHttpHandler(
/// <summary> /// <summary>
/// All protocol versions supported by this implementation. /// All protocol versions supported by this implementation.
/// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core.
/// </summary> /// </summary>
private static readonly HashSet<string> s_supportedProtocolVersions = private static readonly string[] s_supportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions;
[
"2024-11-05",
"2025-03-26",
"2025-06-18",
McpHttpHeaders.November2025ProtocolVersion,
McpHttpHeaders.July2026ProtocolVersion,
];
/// <summary> /// <summary>
/// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and /// 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 /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path
/// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version.
/// </summary> /// </summary>
private static readonly string[] s_sessionSupportingProtocolVersions = private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions;
[.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))];
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>(); private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>(); private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
@ -63,7 +55,8 @@ internal sealed class StreamableHttpHandler(
public async Task HandlePostRequestAsync(HttpContext context) public async Task HandlePostRequestAsync(HttpContext context)
{ {
if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
{ {
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return; return;
@ -111,6 +104,12 @@ internal sealed class StreamableHttpHandler(
// Notifications carry no id, so this stays default (null), which is correct. // Notifications carry no id, so this stays default (null), which is correct.
var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default;
if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError))
{
await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId);
return;
}
if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage))
{ {
await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId);
@ -137,7 +136,8 @@ internal sealed class StreamableHttpHandler(
public async Task HandleGetRequestAsync(HttpContext context) public async Task HandleGetRequestAsync(HttpContext context)
{ {
if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
{ {
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return; return;
@ -148,11 +148,12 @@ internal sealed class StreamableHttpHandler(
// The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited // 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 // 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. // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id.
if (IsJuly2026OrLaterProtocol(context)) if (RequiresPerRequestMetadataProtocol(context))
{ {
context.Response.Headers.Allow = HttpMethods.Post;
await WriteJsonRpcErrorAsync(context, await WriteJsonRpcErrorAsync(context,
"Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.",
StatusCodes.Status400BadRequest); StatusCodes.Status405MethodNotAllowed);
return; return;
} }
@ -254,7 +255,8 @@ internal sealed class StreamableHttpHandler(
public async Task HandleDeleteRequestAsync(HttpContext context) public async Task HandleDeleteRequestAsync(HttpContext context)
{ {
if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion);
if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError))
{ {
await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest);
return; return;
@ -264,11 +266,12 @@ internal sealed class StreamableHttpHandler(
// Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), // 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. // so the DELETE is invalid whether or not it carries an Mcp-Session-Id.
if (IsJuly2026OrLaterProtocol(context)) if (RequiresPerRequestMetadataProtocol(context))
{ {
context.Response.Headers.Allow = HttpMethods.Post;
await WriteJsonRpcErrorAsync(context, await WriteJsonRpcErrorAsync(context,
"Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.",
StatusCodes.Status400BadRequest); StatusCodes.Status405MethodNotAllowed);
return; return;
} }
@ -374,25 +377,14 @@ internal sealed class StreamableHttpHandler(
private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default) private async ValueTask<StreamableHttpSession?> GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default)
{ {
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
// The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // 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: // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions:
if (IsJuly2026OrLaterProtocol(context)) if (RequiresPerRequestMetadataProtocol(context))
{ {
if (!string.IsNullOrEmpty(sessionId))
{
// 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 by the 2026-07-28 and later protocol revisions (SEP-2567).",
StatusCodes.Status400BadRequest, requestId: requestId);
return null;
}
if (!HttpServerTransportOptions.Stateless) if (!HttpServerTransportOptions.Stateless)
{ {
// The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 // 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 // revision cannot provide. Refuse it so a dual-path client falls back to the
// initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics).
await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); await WriteUnsupportedProtocolVersionErrorAsync(context, requestId);
return null; return null;
@ -402,6 +394,7 @@ internal sealed class StreamableHttpHandler(
return await StartNewSessionAsync(context); return await StartNewSessionAsync(context);
} }
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId))
{ {
// In stateful mode, only allow creating new sessions for initialize requests. // In stateful mode, only allow creating new sessions for initialize requests.
@ -435,13 +428,13 @@ internal sealed class StreamableHttpHandler(
/// <summary> /// <summary>
/// Returns <see langword="true"/> when the request's <c>MCP-Protocol-Version</c> header declares a /// 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 /// 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 /// do not use an Mcp-Session-Id and never perform the <c>initialize</c> handshake
/// (SEP-2575 + SEP-2567). /// (SEP-2575 + SEP-2567).
/// </summary> /// </summary>
private static bool IsJuly2026OrLaterProtocol(HttpContext context) private static bool RequiresPerRequestMetadataProtocol(HttpContext context)
{ {
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader);
} }
private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context) private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext context)
@ -665,11 +658,14 @@ internal sealed class StreamableHttpHandler(
/// rejection uses the <see cref="McpErrorCode.UnsupportedProtocolVersion"/> error code with a data payload /// rejection uses the <see cref="McpErrorCode.UnsupportedProtocolVersion"/> error code with a data payload
/// listing the server's supported versions so the client can select a fallback. /// listing the server's supported versions so the client can select a fallback.
/// </summary> /// </summary>
private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) private static bool ValidateProtocolVersionHeader(
HttpContext context,
IReadOnlyList<string> supportedProtocolVersions,
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
{ {
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (!string.IsNullOrEmpty(protocolVersionHeader) && if (!string.IsNullOrEmpty(protocolVersionHeader) &&
!s_supportedProtocolVersions.Contains(protocolVersionHeader)) !supportedProtocolVersions.Contains(protocolVersionHeader))
{ {
errorDetail = new JsonRpcErrorDetail errorDetail = new JsonRpcErrorDetail
{ {
@ -678,7 +674,7 @@ internal sealed class StreamableHttpHandler(
Data = JsonSerializer.SerializeToNode( Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData new UnsupportedProtocolVersionErrorData
{ {
Supported = [.. s_supportedProtocolVersions], Supported = [.. supportedProtocolVersions],
Requested = protocolVersionHeader, Requested = protocolVersionHeader,
}, },
GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()), GetRequiredJsonTypeInfo<UnsupportedProtocolVersionErrorData>()),
@ -690,11 +686,108 @@ internal sealed class StreamableHttpHandler(
return true; return true;
} }
private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion)
{
if (protocolVersion is null)
{
return s_supportedProtocolVersions;
}
if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion))
{
throw new McpException(
$"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " +
string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + ".");
}
return [protocolVersion];
}
/// <summary>
/// Validates that HTTP requests using per-request metadata declare the same protocol version in both
/// the <c>MCP-Protocol-Version</c> header and body <c>_meta</c> envelope.
/// </summary>
private static bool ValidateProtocolVersionEnvelope(
HttpContext context,
JsonRpcMessage message,
[NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail)
{
if (message is not (JsonRpcRequest or JsonRpcNotification))
{
errorDetail = null;
return true;
}
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
{
errorDetail = null;
return true;
}
if (string.IsNullOrEmpty(protocolVersionHeader))
{
errorDetail = CreateHeaderMismatchError(
$"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version.");
return false;
}
if (!hasProtocolVersionMeta)
{
errorDetail = CreateHeaderMismatchError(
$"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version.");
return false;
}
if (!string.Equals(protocolVersionHeader, protocolVersionMeta, StringComparison.Ordinal))
{
errorDetail = CreateHeaderMismatchError(
$"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body _meta/{MetaKeys.ProtocolVersion} value '{protocolVersionMeta}'.");
return false;
}
errorDetail = null;
return true;
}
private static JsonRpcErrorDetail CreateHeaderMismatchError(string message) => new()
{
Code = (int)McpErrorCode.HeaderMismatch,
Message = message,
};
private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWhen(true)] out string? protocolVersion)
{
var parameters = message switch
{
JsonRpcRequest request => request.Params,
JsonRpcNotification notification => notification.Params,
_ => null,
};
string? value = null;
if (parameters is JsonObject paramsObj &&
paramsObj["_meta"] is JsonObject metaObj &&
metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue &&
protocolVersionValue.TryGetValue(out value) &&
!string.IsNullOrEmpty(value))
{
protocolVersion = value;
return true;
}
protocolVersion = null;
return false;
}
/// <summary> /// <summary>
/// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that /// 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 /// 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 /// 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 /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake
/// (SEP-2575). /// (SEP-2575).
/// </summary> /// </summary>
private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default)
@ -703,7 +796,7 @@ internal sealed class StreamableHttpHandler(
var errorDetail = new JsonRpcErrorDetail var errorDetail = new JsonRpcErrorDetail
{ {
Code = (int)McpErrorCode.UnsupportedProtocolVersion, Code = (int)McpErrorCode.UnsupportedProtocolVersion,
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). " + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " +
"Use the initialize handshake with a protocol version that still supports sessions instead.", "Use the initialize handshake with a protocol version that still supports sessions instead.",
Data = JsonSerializer.SerializeToNode( Data = JsonSerializer.SerializeToNode(
new UnsupportedProtocolVersionErrorData new UnsupportedProtocolVersionErrorData
@ -731,7 +824,7 @@ internal sealed class StreamableHttpHandler(
{ {
// Only validate for protocol versions that support standard headers. // Only validate for protocol versions that support standard headers.
var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) if (!McpProtocolVersions.RequiresStandardHeaders(protocolVersion))
{ {
errorMessage = null; errorMessage = null;
return true; return true;

View file

@ -294,24 +294,20 @@ internal sealed partial class McpClientImpl : McpClient
// handshake. Instead, the client calls server/discover to learn the server's // handshake. Instead, the client calls server/discover to learn the server's
// capabilities and then begins sending normal RPCs that carry protocolVersion / // capabilities and then begins sending normal RPCs that carry protocolVersion /
// clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion
// prefers the 2026-07-28 revision and automatically falls back to the legacy initialize // prefers the 2026-07-28 revision and automatically falls back to the initialize
// handshake when the server doesn't support it. The legacy branch below runs only when // handshake when the server doesn't support it. The initialize branch below runs only when
// the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). // 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)) if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion))
{ {
string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion;
// 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; DiscoverResult? discoverResult = null;
bool fallbackToLegacy = false; bool fallbackToInitialize = false;
IList<string>? serverSupportedVersions = null; IList<string>? serverSupportedVersions = null;
string discoverVersion = preferredVersion;
// Apply a probe timeout so dual-era clients don't block forever waiting for a // Apply a probe timeout so dual-path clients don't block forever waiting for an
// legacy server that silently drops unknown methods (per stdio.mdx fallback rules). // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules).
// The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is
// always bounded by InitializationTimeout (only applied when it is the tighter bound). // always bounded by InitializationTimeout (only applied when it is the tighter bound).
var probeTimeout = _options.DiscoverProbeTimeout; var probeTimeout = _options.DiscoverProbeTimeout;
@ -323,75 +319,101 @@ internal sealed partial class McpClientImpl : McpClient
try try
{ {
discoverResult = await SendRequestAsync( discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
RequestMethods.ServerDiscover,
new DiscoverRequestParams(),
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
McpJsonUtilities.JsonContext.Default.DiscoverResult,
cancellationToken: probeCts.Token).ConfigureAwait(false);
} }
catch (UnsupportedProtocolVersionException ex) catch (UnsupportedProtocolVersionException ex)
{ {
// Spec-recognized modern-server signal: -32022 with data.supported[]. The server is // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is
// modern but doesn't speak our preferred version. Retry with a mutually supported // refusing our preferred version. Retry with a supported per-request metadata
// version from data.supported[] instead of falling back to legacy initialize. // version if one exists; otherwise fall back to initialize with the highest
fallbackToLegacy = true; // mutually supported initialize-capable version.
serverSupportedVersions = (IList<string>)ex.Supported; serverSupportedVersions = (IList<string>)ex.Supported;
var retryVersion = serverSupportedVersions
.Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains)
.OrderByDescending(v => v, StringComparer.Ordinal)
.FirstOrDefault();
if (retryVersion is not null)
{
if (_options.ProtocolVersion is { } pinnedVersion &&
StringComparer.Ordinal.Compare(retryVersion, pinnedVersion) < 0)
{
throw new McpException(
$"The server does not support the requested protocol version '{pinnedVersion}'. " +
"Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " +
$"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.");
}
discoverVersion = retryVersion;
discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
}
else
{
fallbackToInitialize = true;
}
} }
catch (MissingRequiredClientCapabilityException) catch (MissingRequiredClientCapabilityException)
{ {
// Spec-recognized modern-server signal: -32021. The server is modern but rejected // Spec-recognized SEP-2575 signal: -32021. The server rejected
// our capability set. Surface as-is (no fallback): the user must add capabilities. // our capability set. Surface as-is (no fallback): the user must add capabilities.
throw; throw;
} }
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch)
{ {
// Spec-recognized modern-server signal: -32020. The server is modern but rejected // Spec-recognized SEP-2575 signal: -32020. The server rejected
// our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match
// the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no
// fallback): falling back to legacy initialize wouldn't fix a malformed envelope. // fallback): falling back to initialize wouldn't fix a malformed envelope.
throw;
}
catch (McpProtocolException ex) when (
ex.ErrorCode == McpErrorCode.InvalidRequest &&
ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal))
{
// Local transport validation: a 2026-07-28+ response must not carry HTTP session state.
// This is not evidence of an initialize-handshake server, so do not fall back.
throw; throw;
} }
catch (McpProtocolException) catch (McpProtocolException)
{ {
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. // 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. // Any non-SEP-2575 JSON-RPC error from the probe indicates an initialize-handshake server.
// Common causes include MethodNotFound from a server that has no // Common causes include MethodNotFound from a server that has no
// server/discover handler, InvalidParams from a server confused by the // server/discover handler, InvalidParams from a server confused by the
// SEP-2575 _meta envelope, ParseError from a server that can't handle our // 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 // payload shape, or any other transport-defined error. The three SEP-2575
// signals (-32022 UnsupportedProtocolVersion, -32021 // signals (-32022 UnsupportedProtocolVersion, -32021
// MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and
// never reach here. // never reach here.
fallbackToLegacy = true; fallbackToInitialize = true;
} }
catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
{ {
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
// response within a reasonable timeout means the server is legacy. Fall back. // response within a reasonable timeout means the server requires initialize. Fall back.
fallbackToLegacy = true; fallbackToInitialize = true;
} }
if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(discoverVersion))
{ {
// Server is reachable and supports server/discover, but doesn't support the // Server is reachable and supports server/discover, but doesn't support the
// 2026-07-28 version. Fall back to legacy initialize with the highest // version we are using. Fall back to initialize with the highest
// mutually-supported version from supportedVersions[]. // mutually-supported initialize-capable version from supportedVersions[].
fallbackToLegacy = true; fallbackToInitialize = true;
serverSupportedVersions = discoverResult.SupportedVersions; serverSupportedVersions = discoverResult.SupportedVersions;
} }
if (fallbackToLegacy) if (fallbackToInitialize)
{ {
// Reset negotiated state and try legacy initialize. // Reset negotiated state and try initialize.
_negotiatedProtocolVersion = null; _negotiatedProtocolVersion = null;
_sessionHandler.NegotiatedProtocolVersion = null; _sessionHandler.NegotiatedProtocolVersion = null;
string fallbackVersion = serverSupportedVersions? string fallbackVersion = serverSupportedVersions?
.Where(McpSessionHandler.SupportedProtocolVersions.Contains) .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains)
.OrderByDescending(v => v, StringComparer.Ordinal) .OrderByDescending(v => v, StringComparer.Ordinal)
.FirstOrDefault() .FirstOrDefault()
?? McpHttpHeaders.November2025ProtocolVersion; ?? McpProtocolVersions.November2025ProtocolVersion;
// A non-null ProtocolVersion is also the minimum: refuse to fall back below the // 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 // explicitly requested version. String.Compare is the spec's prescribed ordering
@ -403,11 +425,11 @@ internal sealed partial class McpClientImpl : McpClient
$"The server does not support the requested protocol version '{pinnedVersion}'. " + $"The server does not support the requested protocol version '{pinnedVersion}'. " +
"Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " +
(serverSupportedVersions is null (serverSupportedVersions is null
? "The server appears to be a legacy server that requires the deprecated initialize handshake." ? "The server appears to require the initialize handshake."
: $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."));
} }
await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false);
} }
else else
{ {
@ -422,14 +444,29 @@ internal sealed partial class McpClientImpl : McpClient
_serverInfo = discoverResult.ServerInfo; _serverInfo = discoverResult.ServerInfo;
_serverInstructions = discoverResult.Instructions; _serverInstructions = discoverResult.Instructions;
} }
async Task<DiscoverResult> SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken)
{
// Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being
// on a per-request metadata revision when SendRequestAsync is invoked for server/discover.
_negotiatedProtocolVersion = protocolVersion;
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
return await SendRequestAsync(
RequestMethods.ServerDiscover,
new DiscoverRequestParams(),
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
McpJsonUtilities.JsonContext.Default.DiscoverResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
} }
else else
{ {
// Legacy initialize handshake. Reached only when the caller explicitly pinned a // initialize handshake. Reached only when the caller explicitly pinned a
// ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so
// _options.ProtocolVersion is non-null here. // _options.ProtocolVersion is non-null here.
string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion;
await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false);
} }
} }
catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
@ -449,10 +486,10 @@ internal sealed partial class McpClientImpl : McpClient
} }
/// <summary> /// <summary>
/// Performs the legacy initialize handshake (initialize request + initialized notification), /// Performs the initialize handshake (initialize request + initialized notification),
/// records the negotiated protocol version, and stores the server capabilities/info/instructions. /// records the negotiated protocol version, and stores the server capabilities/info/instructions.
/// </summary> /// </summary>
private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) private async Task PerformInitializeHandshakeAsync(string requestProtocol, CancellationToken cancellationToken)
{ {
var initializeResponse = await SendRequestAsync( var initializeResponse = await SendRequestAsync(
RequestMethods.Initialize, RequestMethods.Initialize,
@ -479,18 +516,16 @@ internal sealed partial class McpClientImpl : McpClient
_serverInstructions = initializeResponse.Instructions; _serverInstructions = initializeResponse.Instructions;
// When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. // 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 // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate
// server/discover), or when no version was pinned, accept any supported response. This is the // the 2026-07-28 and later protocol revisions.
// spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever
// version the server advertises.
bool isResponseProtocolValid; bool isResponseProtocolValid;
if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) if (_options.ProtocolVersion is { } optionsProtocol && !McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(optionsProtocol))
{ {
isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion;
} }
else else
{ {
isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion);
} }
if (!isResponseProtocolValid) if (!isResponseProtocolValid)
{ {
@ -525,7 +560,7 @@ internal sealed partial class McpClientImpl : McpClient
_serverInstructions = resumeOptions.ServerInstructions; _serverInstructions = resumeOptions.ServerInstructions;
_negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion
?? _options.ProtocolVersion ?? _options.ProtocolVersion
?? McpHttpHeaders.November2025ProtocolVersion; ?? McpProtocolVersions.November2025ProtocolVersion;
// Update session handler with the negotiated protocol version for telemetry // Update session handler with the negotiated protocol version for telemetry
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
@ -691,7 +726,7 @@ internal sealed partial class McpClientImpl : McpClient
/// <summary> /// <summary>
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields (protocol version, client info, /// 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 /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision
/// (SEP-2575). No-op on a legacy session. /// (SEP-2575). No-op on an initialize-handshake session.
/// </summary> /// </summary>
private void InjectRequestMetaIfNeeded(JsonRpcRequest request) private void InjectRequestMetaIfNeeded(JsonRpcRequest request)
{ {

View file

@ -52,16 +52,20 @@ public sealed class McpClientOptions
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Supported values are <c>2024-11-05</c>, <c>2025-03-26</c>, <c>2025-06-18</c>, <c>2025-11-25</c>,
/// and <c>2026-07-28</c>.
/// </para>
/// <para>
/// When <see langword="null"/> (the default), the client prefers the latest revision (<c>2026-07-28</c>), /// 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 /// 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, /// <c>server/discover</c> and automatically falls back to the <c>initialize</c> handshake,
/// downgrading to any version the server advertises, when the server does not support that revision. /// downgrading to an initialize-capable version the server advertises, when the server does not support that revision.
/// </para> /// </para>
/// <para> /// <para>
/// When non-<see langword="null"/>, this value is both the requested version and the minimum the client /// 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 /// 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 /// <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 /// the automatic initialize-handshake 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 /// 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 /// negotiates a different version. To try more than one version, leave this unset for automatic fallback
/// or retry the connection with a different value. /// or retry the connection with a different value.
@ -90,7 +94,7 @@ public sealed class McpClientOptions
/// <summary> /// <summary>
/// Gets or sets the timeout applied to the <c>server/discover</c> probe that the client issues /// Gets or sets the timeout applied to the <c>server/discover</c> probe that the client issues
/// before falling back to the legacy <c>initialize</c> handshake. /// before falling back to the <c>initialize</c> handshake.
/// </summary> /// </summary>
/// <value> /// <value>
/// The probe timeout. The default value is 5 seconds. Use /// The probe timeout. The default value is 5 seconds. Use
@ -102,17 +106,17 @@ public sealed class McpClientOptions
/// This timeout only has an effect when the client prefers the <c>2026-07-28</c> protocol revision, that is, /// 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>. /// 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 /// 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 /// <c>server/discover</c> request. A 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 /// 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 /// client concludes the server requires <c>initialize</c> and falls back to that handshake on the
/// same connection. When the caller pins a legacy <see cref="ProtocolVersion"/>, no probe is issued /// same connection. When the caller pins an initialize-capable <see cref="ProtocolVersion"/>, no probe is issued
/// and this value has no effect. /// and this value has no effect.
/// </para> /// </para>
/// <para> /// <para>
/// The default is intentionally short so that dual-era clients fall back quickly against legacy /// The default is intentionally short so that dual-path clients fall back quickly against initialize-handshake
/// servers. Increase it for high-latency environments (for example, cold-start serverless peers or /// 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 server on the new revision /// satellite links) where a short probe could trigger the initialize fallback before a server on the
/// server has had a chance to respond. The probe is always also bounded by /// per-request metadata revision 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 /// <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 /// greater than or equal to <see cref="InitializationTimeout"/>, the probe is effectively bounded by
/// <see cref="InitializationTimeout"/> alone. /// <see cref="InitializationTimeout"/> alone.

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 // 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 // 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 // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the
// connect-time logic can react. For example, the three modern protocol error codes // connect-time logic can react. For example, the three per-request metadata protocol error codes
// (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability,
// -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from // -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 // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic
// McpProtocolException instances and trigger the fallback-to-legacy-initialize path. // McpProtocolException instances and trigger the initialize-handshake fallback path.
// Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // 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. // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors.
// The three modern protocol error codes are also surfaced for non-400 status codes // The three per-request metadata protocol error codes are also surfaced for non-400 status codes
// for robustness. Servers occasionally emit them with 4xx codes other than 400. // for robustness. Servers occasionally emit them with 4xx codes other than 400.
if (!response.IsSuccessStatusCode && if (!response.IsSuccessStatusCode &&
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
(response.StatusCode == HttpStatusCode.BadRequest || (response.StatusCode == HttpStatusCode.BadRequest ||
IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
{ {
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
} }
@ -87,7 +87,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
} }
private static bool IsModernProtocolErrorCode(McpErrorCode code) => private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) =>
code is McpErrorCode.UnsupportedProtocolVersion code is McpErrorCode.UnsupportedProtocolVersion
or McpErrorCode.MissingRequiredClientCapability or McpErrorCode.MissingRequiredClientCapability
or McpErrorCode.HeaderMismatch; or McpErrorCode.HeaderMismatch;
@ -217,7 +217,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse)
{ {
// We've successfully initialized! Copy session-id and protocol version, then start GET request if any. // We've successfully initialized! Copy session-id and protocol version, then start GET request if any.
if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) if (response.Headers.TryGetValues(McpHttpHeaders.SessionId, out var sessionIdValues))
{ {
SessionId = sessionIdValues.FirstOrDefault(); SessionId = sessionIdValues.FirstOrDefault();
} }
@ -528,7 +528,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
string? protocolVersion, string? protocolVersion,
string? lastEventId = null) string? lastEventId = null)
{ {
if (sessionId is not null) if (sessionId is not null && McpProtocolVersions.SupportsHttpSessions(protocolVersion))
{ {
headers.Add(McpHttpHeaders.SessionId, sessionId); headers.Add(McpHttpHeaders.SessionId, sessionId);
} }

View file

@ -55,7 +55,7 @@ public abstract partial class McpSession : IAsyncDisposable
/// definition of "is this peer speaking the 2026-07-28 or later 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> /// </remarks>
internal bool IsJuly2026OrLaterProtocol() => internal bool IsJuly2026OrLaterProtocol() =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion);
/// <summary> /// <summary>
/// Sends a JSON-RPC request to the connected session and waits for a response. /// Sends a JSON-RPC request to the connected session and waits for a response.

View file

@ -29,18 +29,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"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."); "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> /// <summary>
/// All protocol versions supported by this implementation. The version constants live on /// All protocol versions supported by this implementation. The era-specific lists live on
/// <see cref="McpHttpHeaders"/> so the shared source file is the single source of truth. /// <see cref="McpHttpHeaders"/> so the shared source file is the single source of truth.
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
/// </summary> /// </summary>
internal static readonly string[] SupportedProtocolVersions = internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions;
[
"2024-11-05",
"2025-03-26",
"2025-06-18",
McpHttpHeaders.November2025ProtocolVersion,
McpHttpHeaders.July2026ProtocolVersion,
];
/// <summary> /// <summary>
/// Checks if the given protocol version supports priming events. /// Checks if the given protocol version supports priming events.
@ -53,7 +45,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// </remarks> /// </remarks>
internal static bool SupportsPrimingEvent(string? protocolVersion) internal static bool SupportsPrimingEvent(string? protocolVersion)
{ {
const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion;
if (protocolVersion is null) if (protocolVersion is null)
{ {
@ -81,7 +73,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
return false; return false;
} }
return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0;
} }
private readonly bool _isServer; private readonly bool _isServer;
@ -162,7 +154,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
(request, jsonRpcRequest, cancellationToken) => (request, jsonRpcRequest, cancellationToken) =>
{ {
string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion))
{ {
throw new McpProtocolException( throw new McpProtocolException(
$"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.",
@ -583,8 +575,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already // 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 // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
// -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), // -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 // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather
// than mistaking it for a legacy server and falling back to the initialize handshake. // than mistaking it for an initialize-handshake server and falling back to initialize.
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
{ {
throw new McpProtocolException( throw new McpProtocolException(
@ -618,7 +610,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Used by <see cref="Client.McpClient"/> on a 2026-07-28 or later session to carry protocol version, client /// 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 /// info, and client capabilities on every outgoing request (replacing what the
/// <c>initialize</c> handshake previously negotiated once). /// <c>initialize</c> handshake previously negotiated once).
/// </remarks> /// </remarks>
internal static void InjectRequestMeta( internal static void InjectRequestMeta(

View file

@ -32,6 +32,7 @@
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" /> <Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.cs" /> <Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.cs" />
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" /> <Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
<Compile Include="..\Common\McpProtocolVersions.cs" Link="McpProtocolVersions.cs" />
<Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" /> <Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" /> <Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup> </ItemGroup>

View file

@ -8,16 +8,19 @@ namespace ModelContextProtocol.Protocol;
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client /// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client
/// to learn what a server supports without performing the legacy <c>initialize</c> handshake. /// to learn what a server supports without performing the <c>initialize</c> handshake.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class DiscoverResult : Result, ICacheableResult public sealed class DiscoverResult : Result, ICacheableResult
{ {
/// <summary> /// <summary>
/// Gets or sets the list of MCP protocol version strings that the server supports. /// Gets or sets the list of MCP protocol version strings the server supports for subsequent
/// per-request metadata requests.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The client should choose a version from this list for use in subsequent requests. /// The client should choose a version from this list for subsequent requests that carry the
/// 2026-07-28-style per-request <c>_meta</c> envelope. Versions that require the
/// <c>initialize</c> handshake are negotiated through <c>initialize</c> instead.
/// </remarks> /// </remarks>
[JsonPropertyName("supportedVersions")] [JsonPropertyName("supportedVersions")]
public required IList<string> SupportedVersions { get; set; } public required IList<string> SupportedVersions { get; set; }

View file

@ -22,16 +22,17 @@ namespace ModelContextProtocol.Protocol;
public sealed class InitializeRequestParams : RequestParams public sealed class InitializeRequestParams : RequestParams
{ {
/// <summary> /// <summary>
/// Gets or sets the version of the Model Context Protocol that the client wants to use. /// Gets or sets the initialize-handshake Model Context Protocol version that the client wants to use.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD". /// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD".
/// The client and server must agree on a protocol version to communicate successfully. /// The client and server must agree on an initialize-capable protocol version to communicate successfully.
/// </para> /// </para>
/// <para> /// <para>
/// During initialization, the server will check if it supports this requested version. If there's a /// During initialization, the server will check if it supports this requested version. Protocol
/// mismatch, the server will reject the connection with a version mismatch error. /// revisions starting with <c>2026-07-28</c> do not use <c>initialize</c>; clients select them with
/// <c>server/discover</c> and per-request metadata instead.
/// </para> /// </para>
/// <para> /// <para>
/// See the <see href="https://spec.modelcontextprotocol.io/specification/">protocol specification</see> for version details. /// See the <see href="https://spec.modelcontextprotocol.io/specification/">protocol specification</see> for version details.

View file

@ -22,11 +22,11 @@ namespace ModelContextProtocol.Protocol;
public sealed class InitializeResult : Result public sealed class InitializeResult : Result
{ {
/// <summary> /// <summary>
/// Gets or sets the version of the Model Context Protocol that the server will use for this session. /// Gets or sets the initialize-handshake Model Context Protocol version that the server will use for this session.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// This is the protocol version the server has agreed to use, which should match the client's /// This is the initialize-capable protocol version the server has agreed to use, which should match the client's
/// requested version. If there's a mismatch, the client should throw an exception to prevent /// requested version. If there's a mismatch, the client should throw an exception to prevent
/// communication issues due to incompatible protocol versions. /// communication issues due to incompatible protocol versions.
/// </para> /// </para>

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