diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 78782bfbb..003723e39 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,10 +172,10 @@ Here's an example implementation of how a console application might handle elici ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -196,7 +196,7 @@ public static string ElicitWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request user input diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 1d1ebce32..a8fa3df0f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -9,7 +9,7 @@ uid: mrtr > [!WARNING] -> MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. +> MRTR is part of the **`2026-07-28`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. @@ -33,13 +33,13 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`** during `initialize`. The C# SDK opts in by listing `2026-07-28` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. ```csharp // Client var clientOptions = new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Handlers = new McpClientHandlers { ElicitationHandler = HandleElicitationAsync, @@ -48,7 +48,7 @@ var clientOptions = new McpClientOptions }; ``` -Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. +Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. @@ -60,7 +60,7 @@ A tool participates in MRTR by throwing before throwing `InputRequiredException`. It returns `true` when either: -- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or +- The negotiated protocol revision is `2026-07-28` (MRTR is native), or - The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp @@ -71,7 +71,7 @@ public static string MyTool( { if (!server.IsMrtrSupported) { - return "This tool requires a client that negotiates DRAFT-2026-v1, " + return "This tool requires a client that negotiates 2026-07-28, " + "or a stateful current-protocol session."; } @@ -258,7 +258,7 @@ When MRTR is not supported, you can provide domain-specific guidance: if (!server.IsMrtrSupported) { return "This tool requires interactive input. To use it:\n" - + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" + + "1. Connect with a client that negotiates MCP protocol revision 2026-07-28, or\n" + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; } @@ -270,22 +270,22 @@ The SDK supports `InputRequiredException` across two protocol revisions and two | Negotiated protocol | Session mode | Behavior | |---|---|---| -| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | -| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | | Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | | Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | > [!NOTE] -> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `2026-07-28` (check `IsMrtrSupported`). ### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. +Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. ### Future direction -The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. +The `2026-07-28` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `2026-07-28` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. This work is a follow-up to the present PR. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 213d317c0..220887fae 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,10 +106,10 @@ server.RegisterNotificationHandler( ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -128,7 +128,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request the client's root list diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index bac6ed5ab..1dd0b90ec 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,10 +123,10 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` 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 `DRAFT-2026-v1` 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 — 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. For example: @@ -146,7 +146,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request LLM completion from the client diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 68ce52f33..44ef911fc 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -1,797 +1,828 @@ --- -title: Stateless and stateful mode -author: halter73 -description: When to use stateless vs. stateful mode in the MCP C# SDK, server-side session management, client-side session lifecycle, and distributed tracing. -uid: stateless + +title: Stateless and stateful mode +author: halter73 +description: When to use stateless vs. stateful mode in the MCP C# SDK, server-side session management, client-side session lifecycle, and distributed tracing. +uid: stateless + --- -# Stateless and stateful mode +# Stateless and stateful mode + +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests *to* the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. + +When sessions are enabled (`Stateless = false`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). + +[Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. +**Quick guide — which mode should I use?** -When sessions are enabled (the current C# SDK default), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). +- Does your server need to send requests *to* the client (sampling, elicitation, roots)? → **Use stateful.** +- Does your server send [unsolicited notifications](#how-streamable-http-delivers-messages) or support resource subscriptions? → **Use stateful.** +- Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with (disabled by default due to [backpressure concerns](#request-backpressure)). +- Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.** +- Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.** +- Otherwise → **Use stateless** (`options.Stateless = true`). -[Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + -**Quick guide — which mode should I use?** +> [!NOTE] +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful for back-compat with the `2025-11-25` (and older) protocol revisions, which require the `Mcp-Session-Id` header. The `2026-07-28` draft revision removes that header (SEP-2567) and the `initialize` handshake (SEP-2575) entirely, so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` to keep using legacy-protocol features like server-to-client requests (sampling, elicitation, roots), unsolicited notifications, or per-client isolation — see [Stateful mode (sessions)](#stateful-mode-sessions). -- Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** -- Does your server send [unsolicited notifications](#how-streamable-http-delivers-messages) or support resource subscriptions? → **Use stateful.** -- Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with (disabled by default due to [backpressure concerns](#request-backpressure)). -- Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.** -- Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.** -- Otherwise → **Use stateless** (`options.Stateless = true`). +## Forward and backward compatibility - -> [!NOTE] -> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features — see [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing an explicit setting. +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` draft revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([sampling](xref:sampling), [elicitation](xref:elicitation), [roots](xref:roots), [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions). [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) provides a sessionless alternative for sampling, elicitation, and roots. We recommend every server set `Stateless` explicitly rather than relying on the default: -## Forward and backward compatibility +- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` draft revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to draft clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`Stateless = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients will always honor your server's session. With [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) now merged, sampling, elicitation, and roots are also available to stateless servers — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). Unsolicited notifications and resource subscriptions still require sessions. Note that even with `Stateless = false`, draft (`2026-07-28`) requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision. -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The current C# SDK default is `Stateless = false` (sessions enabled), but **we expect this default to change** once mechanisms like [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) bring server-to-client interactions (sampling, elicitation, roots) to stateless mode. We recommend every server set `Stateless` explicitly rather than relying on the default: + -- **`Stateless = true`** — the best forward-compatible choice. Your server opts out of sessions entirely. No matter how the SDK default changes in the future, your behavior stays the same. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +> [!TIP] +> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. -- **`Stateless = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients will always honor your server's session. Once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or a similar mechanism is available, you may be able to migrate server-to-client interactions to stateless mode and drop sessions entirely — but until then, explicit `Stateless = false` is the safe choice. See [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions) for more on MRTR. +### The 2026-07-28 draft revision - -> [!TIP] -> If you're not sure which to pick, start with `Stateless = true`. You can switch to `Stateless = false` later if you discover you need server-to-client requests or unsolicited notifications. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +The `2026-07-28` draft revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -### Migrating from legacy SSE +**Server side.** With `Stateless = true` (the default), the SDK already meets the draft on the wire. Any HTTP POST that arrives with the draft `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but draft requests on a stateful server are still served sessionlessly because the protocol forbids the session header. -If your clients connect to a `/sse` endpoint (e.g., `https://my-server.example.com/sse`), they are using the [legacy SSE transport](#legacy-sse-transport) — regardless of any `Stateless` or session settings on the server. The `/sse` and `/message` endpoints are now **disabled by default** ( is `false` and marked `[Obsolete]` with diagnostic `MCP9004`). Upgrading the server SDK without updating clients will break SSE connections. +**Stateful options marked obsolete.** Because the draft revision is unconditionally sessionless, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9005` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. -**Client-side migration.** Change the client `Endpoint` from the `/sse` path to the root MCP endpoint — the same URL your server passes to `MapMcp()`. For example: +**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it: + +- **HTTP**: the client sends its first request with the draft `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32004` / `-32003` / `-32001` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms the draft revision; a `-32004` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. + +The era is cached per instance, so the probe cost is paid only on the first connect. + +**Opting out of fallback.** Set to when you want the client to refuse to fall back. The connect call throws an instead of silently degrading. This is useful for strict-modern production code and for tests that need to assert draft-only behavior. ```csharp -// Before (legacy SSE): -Endpoint = new Uri("https://my-server.example.com/sse") +var clientOptions = new McpClientOptions +{ + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, +}; +``` + +### Migrating from legacy SSE -// After (Streamable HTTP): -Endpoint = new Uri("https://my-server.example.com/") +If your clients connect to a `/sse` endpoint (e.g., `https://my-server.example.com/sse`), they are using the [legacy SSE transport](#legacy-sse-transport) — regardless of any `Stateless` or session settings on the server. The `/sse` and `/message` endpoints are now **disabled by default** ( is `false` and marked `[Obsolete]` with diagnostic `MCP9004`). Upgrading the server SDK without updating clients will break SSE connections. + +**Client-side migration.** Change the client `Endpoint` from the `/sse` path to the root MCP endpoint — the same URL your server passes to `MapMcp()`. For example: + +```csharp +// Before (legacy SSE): +Endpoint = new Uri("https://my-server.example.com/sse") + +// After (Streamable HTTP): +Endpoint = new Uri("https://my-server.example.com/") ``` -With the default transport mode, the client automatically tries Streamable HTTP first. You can also set `TransportMode = HttpTransportMode.StreamableHttp` explicitly if you know the server supports it. +With the default transport mode, the client automatically tries Streamable HTTP first. You can also set `TransportMode = HttpTransportMode.StreamableHttp` explicitly if you know the server supports it. -**Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`. +**Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`. -**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `Stateless = false`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `Stateless = true`. +**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `Stateless = false`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `Stateless = true`. -## Stateless mode (recommended) +## Stateless mode (recommended) -Stateless mode is the recommended default for HTTP-based MCP servers. When enabled, the server doesn't track any state between requests, doesn't use the `Mcp-Session-Id` header, and treats each request independently. This is the simplest and most scalable deployment model. +Stateless mode is the recommended default for HTTP-based MCP servers. When enabled, the server doesn't track any state between requests, doesn't use the `Mcp-Session-Id` header, and treats each request independently. This is the simplest and most scalable deployment model. -### Enabling stateless mode +### Enabling stateless mode ```csharp -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddMcpServer() - .WithHttpTransport(options => - { - options.Stateless = true; - }) - .WithTools(); - -var app = builder.Build(); -app.MapMcp(); -app.Run(); +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools(); + +var app = builder.Build(); +app.MapMcp(); +app.Run(); ``` -### What stateless mode changes +### What stateless mode changes -When is `true`: +When is `true`: -- is `null`, and the `Mcp-Session-Id` header is not sent or expected -- Each HTTP request creates a fresh server context — no state carries over between requests -- still works, but is called **per HTTP request** rather than once per session (see [Per-request configuration in stateless mode](#per-request-configuration-in-stateless-mode)) -- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#legacy-sse-transport) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect -- **Server-to-client requests are disabled**, including: - - [Sampling](xref:sampling) (`SampleAsync`) - - [Elicitation](xref:elicitation) (`ElicitAsync`) - - [Roots](xref:roots) (`RequestRootsAsync`) - - Ping — the server cannot ping the client to verify connectivity +- is `null`, and the `Mcp-Session-Id` header is not sent or expected +- Each HTTP request creates a fresh server context — no state carries over between requests +- still works, but is called **per HTTP request** rather than once per session (see [Per-request configuration in stateless mode](#per-request-configuration-in-stateless-mode)) +- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#legacy-sse-transport) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect +- **Server-to-client requests are disabled**, including: + - [Sampling](xref:sampling) (`SampleAsync`) + - [Elicitation](xref:elicitation) (`ElicitAsync`) + - [Roots](xref:roots) (`RequestRootsAsync`) + - Ping — the server cannot ping the client to verify connectivity - The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. -- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. -- **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. -- **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. -- [Tasks](xref:tasks) **are supported** — the task store is shared across ephemeral server instances. However, task-augmented sampling and elicitation are disabled because they require server-to-client requests. + [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) brings sampling, elicitation, and roots to stateless mode when both client and server opt in — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). -These restrictions exist because in a stateless deployment, responses from the client could arrive at any server instance — not necessarily the one that sent the request. +- [**Unsolicited**](#how-streamable-http-delivers-messages) **server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. +- **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. +- **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. +- [Tasks](xref:tasks) **are supported** — the task store is shared across ephemeral server instances. However, task-augmented sampling and elicitation are disabled because they require server-to-client requests. -### When to use stateless mode +These restrictions exist because in a stateless deployment, responses from the client could arrive at any server instance — not necessarily the one that sent the request. -Use stateless mode when your server: +### When to use stateless mode -- Exposes tools that are pure functions (take input, return output) -- Doesn't need to ask the client for user input (elicitation) or LLM completions (sampling) -- Doesn't need to send unsolicited notifications to the client -- Needs to scale horizontally behind a load balancer without session affinity -- Is deployed to serverless environments (Azure Functions, AWS Lambda, etc.) +Use stateless mode when your server: -Most MCP servers fall into this category. Tools that call APIs, query databases, process data, or return computed results are all natural fits for stateless mode. See [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing between stateless and stateful mode. +- Exposes tools that are pure functions (take input, return output) +- Doesn't need to ask the client for user input (elicitation) or LLM completions (sampling) +- Doesn't need to send unsolicited notifications to the client +- Needs to scale horizontally behind a load balancer without session affinity +- Is deployed to serverless environments (Azure Functions, AWS Lambda, etc.) -### Stateless alternatives for server-to-client interactions +Most MCP servers fall into this category. Tools that call APIs, query databases, process data, or return computed results are all natural fits for stateless mode. See [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing between stateless and stateful mode. - -> [!NOTE] -> Multi Round-Trip Requests (MRTR) is a proposed experimental feature that is not yet available. See PR [#1458](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for the reference implementation and specification proposal. +### Stateless alternatives for server-to-client interactions -The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a proposed alternative that works with stateless servers by inverting the communication model — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. +The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -This means servers that need user confirmation, LLM reasoning, or other client input can still run in stateless mode when both sides support MRTR. +This means servers that need user confirmation, LLM reasoning, or other client input can run in stateless mode when both sides support MRTR. -## Stateful mode (sessions) +## Stateful mode (sessions) -When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: -- Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream -- [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the GET stream -- Resource subscriptions -- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) +- Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream +- [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the GET stream +- Resource subscriptions +- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) -### When to use stateful mode +### When to use stateful mode -Use stateful mode when your server needs one or more of: +Use stateful mode when your server needs one or more of: -- **Server-to-client requests**: Tools that call `ElicitAsync`, `SampleAsync`, or `RequestRootsAsync` to interact with the client -- **[Unsolicited notifications](#how-streamable-http-delivers-messages)**: Sending resource-changed notifications or log messages outside the context of any active request handler — these require the [GET stream](#how-streamable-http-delivers-messages) -- **Resource subscriptions**: Clients subscribing to resource changes and receiving updates -- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#legacy-sse-transport) — requires (disabled by default) -- **Session-scoped state**: Logic that must persist across multiple requests within the same session -- **Concurrent client isolation**: Multiple agents or editor instances connecting simultaneously, where per-client state must not leak between users — separate working environments, independent scratch state, or parallel simulations where each participant needs its own context. The server — not the model — controls when sessions are created, so the harness decides the boundaries of isolation. -- **Local development and debugging**: Testing a typically-stdio server over HTTP where you want to attach a debugger, see log output on stdout, and have editors like Claude Code, GitHub Copilot in VS Code, and Cursor reset the server's state by starting a new session — without requiring a process restart. This closely mirrors the stdio experience where restarting the server process gives the client a clean slate. +- **Server-to-client requests**: Tools that call `ElicitAsync`, `SampleAsync`, or `RequestRootsAsync` to interact with the client +- [**Unsolicited notifications**](#how-streamable-http-delivers-messages): Sending resource-changed notifications or log messages outside the context of any active request handler — these require the [GET stream](#how-streamable-http-delivers-messages) +- **Resource subscriptions**: Clients subscribing to resource changes and receiving updates +- **Legacy SSE client support**: Clients that only speak the [legacy SSE transport](#legacy-sse-transport) — requires (disabled by default) +- **Session-scoped state**: Logic that must persist across multiple requests within the same session +- **Concurrent client isolation**: Multiple agents or editor instances connecting simultaneously, where per-client state must not leak between users — separate working environments, independent scratch state, or parallel simulations where each participant needs its own context. The server — not the model — controls when sessions are created, so the harness decides the boundaries of isolation. +- **Local development and debugging**: Testing a typically-stdio server over HTTP where you want to attach a debugger, see log output on stdout, and have editors like Claude Code, GitHub Copilot in VS Code, and Cursor reset the server's state by starting a new session — without requiring a process restart. This closely mirrors the stdio experience where restarting the server process gives the client a clean slate. -The [deployment considerations](#deployment-considerations) below are real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set. +The [deployment considerations](#deployment-considerations) below are real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set. -## Comparison +## Comparison | Consideration | Stateless | Stateful | -|---|---|---| +| --- | --- | --- | | **Deployment** | Any topology — load balancer, serverless, multi-instance | Requires session affinity (sticky sessions) | | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | | **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) | -| **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | -| **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | +| **Server-to-client requests** | Not supported by the legacy protocol; available via [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for sampling, elicitation, and roots | Supported (sampling, elicitation, roots) | +| [**Unsolicited notifications**](#how-streamable-http-delivers-messages) | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | | **Local development** | Works, but no way to reset server state from the editor | Editors can reset state by starting a new session without restarting the process | | **Concurrent client isolation** | No distinction between clients — all requests are independent | Each client gets its own session with isolated state | | **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate | -| **[Tasks](xref:tasks)** | Supported — shared task store, no per-session isolation | Supported — task store scoped per session | +| [**Tasks**](xref:tasks) | Supported — shared task store, no per-session isolation | Supported — task store scoped per session | -## Transports and sessions +## Transports and sessions -### Streamable HTTP +### Streamable HTTP -#### How Streamable HTTP delivers messages +#### How Streamable HTTP delivers messages -Understanding how messages flow between client and server over HTTP is key to understanding why sessions exist and when you can avoid them. +Understanding how messages flow between client and server over HTTP is key to understanding why sessions exist and when you can avoid them. -**POST response streams (solicited messages).** Every JSON-RPC request from the client arrives as an HTTP POST. The server holds the POST response body open as a [Server-Sent Events (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream and writes messages back to it: the JSON-RPC response, any intermediate messages the handler produces (progress notifications, log messages), and — critically — any **server-to-client requests** the handler makes during execution, such as sampling, elicitation, or roots requests. This is a **solicited** interaction: the client's POST request solicited the server's response, and the server writes everything related to that request into the same HTTP response body. The POST response completes when the final JSON-RPC response is sent. +**POST response streams (solicited messages).** Every JSON-RPC request from the client arrives as an HTTP POST. The server holds the POST response body open as a [Server-Sent Events (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream and writes messages back to it: the JSON-RPC response, any intermediate messages the handler produces (progress notifications, log messages), and — critically — any **server-to-client requests** the handler makes during execution, such as sampling, elicitation, or roots requests. This is a **solicited** interaction: the client's POST request solicited the server's response, and the server writes everything related to that request into the same HTTP response body. The POST response completes when the final JSON-RPC response is sent. -**The GET stream (unsolicited messages).** The client can optionally open a long-lived HTTP GET request to the same MCP endpoint. This stream is the **only** channel for **unsolicited** messages — notifications or server-to-client requests that the server initiates _outside the context of any active request handler_. For example: +**The GET stream (unsolicited messages).** The client can optionally open a long-lived HTTP GET request to the same MCP endpoint. This stream is the **only** channel for **unsolicited** messages — notifications or server-to-client requests that the server initiates *outside the context of any active request handler*. For example: -- A resource-changed notification fired by a background file watcher -- A log message emitted asynchronously after all request handlers have returned -- A server-to-client request that isn't triggered by a tool call +- A resource-changed notification fired by a background file watcher +- A log message emitted asynchronously after all request handlers have returned +- A server-to-client request that isn't triggered by a tool call -These messages are "unsolicited" because no client POST solicited them. There is no POST response body to write them to — because outside of POST requests that solicit the server 1:1 with a JSON-RPC request, there is simply no HTTP response body stream available. The GET stream fills this gap. +These messages are "unsolicited" because no client POST solicited them. There is no POST response body to write them to — because outside of POST requests that solicit the server 1:1 with a JSON-RPC request, there is simply no HTTP response body stream available. The GET stream fills this gap. -**No GET stream = messages silently dropped.** Clients are not required to open a GET stream. If the client hasn't opened one, the server has no delivery path for unsolicited messages and silently drops them. This is by design in the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) — unsolicited messages are best-effort. +**No GET stream = messages silently dropped.** Clients are not required to open a GET stream. If the client hasn't opened one, the server has no delivery path for unsolicited messages and silently drops them. This is by design in the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) — unsolicited messages are best-effort. -**Why stateless mode can't support unsolicited messages.** In stateless mode, the GET endpoint is not mapped at all. Every message the server sends must be part of a POST response — there is no other HTTP response body to write to. This is also why server-to-client requests (sampling, elicitation, roots) are disabled: the server could initiate a request down the POST response stream during a handler, but the client's response to that request would arrive as a _new_ POST — which in stateless mode creates a completely independent server context with no connection to the original handler. The server has no way to correlate the client's reply with the handler that asked the question. Sessions solve this by keeping the handler alive across multiple HTTP round-trips within the same in-memory session. +**Why stateless mode can't support unsolicited messages.** In stateless mode, the GET endpoint is not mapped at all. Every message the server sends must be part of a POST response — there is no other HTTP response body to write to. This is also why server-to-client requests (sampling, elicitation, roots) are disabled: the server could initiate a request down the POST response stream during a handler, but the client's response to that request would arrive as a *new* POST — which in stateless mode creates a completely independent server context with no connection to the original handler. The server has no way to correlate the client's reply with the handler that asked the question. Sessions solve this by keeping the handler alive across multiple HTTP round-trips within the same in-memory session. -#### Session lifecycle +#### Session lifecycle -A session begins when a client sends an `initialize` JSON-RPC request without an `Mcp-Session-Id` header. The server: +A session begins when a client sends an `initialize` JSON-RPC request without an `Mcp-Session-Id` header. The server: -1. Creates a new session with a unique session ID -2. Calls (if configured) to customize the session's `McpServerOptions` -3. Starts the MCP server for the session -4. Returns the session ID in the `Mcp-Session-Id` response header along with the `InitializeResult` +1. Creates a new session with a unique session ID +2. Calls (if configured) to customize the session's `McpServerOptions` +3. Starts the MCP server for the session +4. Returns the session ID in the `Mcp-Session-Id` response header along with the `InitializeResult` -All subsequent requests from the client must include this session ID. +All subsequent requests from the client must include this session ID. -#### Activity tracking +#### Activity tracking -The server tracks the last activity time for each Streamable HTTP session. Activity is recorded when: +The server tracks the last activity time for each Streamable HTTP session. Activity is recorded when: -- A request arrives for the session (POST or GET) -- A response is sent for the session +- A request arrives for the session (POST or GET) +- A response is sent for the session -#### Idle timeout +#### Idle timeout -Streamable HTTP sessions that have no activity for the duration of (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds. +Streamable HTTP sessions that have no activity for the duration of (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds. -A client can keep its session alive by maintaining any open HTTP request (e.g., a long-running POST with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle. +A client can keep its session alive by maintaining any open HTTP request (e.g., a long-running POST with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle. -When a session times out: +When a session times out: -- The session's `McpServer` is disposed -- Any pending requests receive cancellation -- A client trying to use the expired session ID receives a `404 Session not found` error and should start a new session +- The session's `McpServer` is disposed +- Any pending requests receive cancellation +- A client trying to use the expired session ID receives a `404 Session not found` error and should start a new session -You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though this is not recommended for production deployments. +You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though this is not recommended for production deployments. -#### Maximum idle session count +#### Maximum idle session count - (default: **10,000**) limits how many idle Streamable HTTP sessions can exist simultaneously. If this limit is exceeded: + (default: **10,000**) limits how many idle Streamable HTTP sessions can exist simultaneously. If this limit is exceeded: -- A critical error is logged -- The oldest idle sessions are terminated (even if they haven't reached their idle timeout) -- Termination continues until the idle count is back below the limit +- A critical error is logged +- The oldest idle sessions are terminated (even if they haven't reached their idle timeout) +- Termination continues until the idle count is back below the limit -Sessions with any active HTTP request don't count toward this limit. +Sessions with any active HTTP request don't count toward this limit. -#### Termination +#### Termination -Streamable HTTP sessions can be terminated by: +Streamable HTTP sessions can be terminated by: -- **Client DELETE request**: The client sends an HTTP `DELETE` to the session endpoint with its `Mcp-Session-Id` -- **Idle timeout**: The session exceeds the idle timeout without activity -- **Max idle count**: The server exceeds its maximum idle session count and prunes the oldest sessions -- **Server shutdown**: All sessions are disposed when the server shuts down +- **Client DELETE request**: The client sends an HTTP `DELETE` to the session endpoint with its `Mcp-Session-Id` +- **Idle timeout**: The session exceeds the idle timeout without activity +- **Max idle count**: The server exceeds its maximum idle session count and prunes the oldest sessions +- **Server shutdown**: All sessions are disposed when the server shuts down -#### Deployment considerations +#### Deployment considerations -Stateful sessions introduce several challenges for production, internet-facing services: +Stateful sessions introduce several challenges for production, internet-facing services: -**Session affinity required.** All requests for a given session must reach the same server instance, because sessions live in memory. If you deploy behind a load balancer, you must configure session affinity (sticky sessions) to route requests to the correct instance. Without session affinity, clients will receive `404 Session not found` errors. +**Session affinity required.** All requests for a given session must reach the same server instance, because sessions live in memory. If you deploy behind a load balancer, you must configure session affinity (sticky sessions) to route requests to the correct instance. Without session affinity, clients will receive `404 Session not found` errors. -**Memory consumption.** Each session consumes memory on the server for the lifetime of the session. The default idle timeout is **2 hours**, and the default maximum idle session count is **10,000**. A server with many concurrent clients can accumulate significant memory usage. Monitor your idle session count and tune and to match your workload. +**Memory consumption.** Each session consumes memory on the server for the lifetime of the session. The default idle timeout is **2 hours**, and the default maximum idle session count is **10,000**. A server with many concurrent clients can accumulate significant memory usage. Monitor your idle session count and tune and to match your workload. -**Server restarts lose all sessions.** Sessions are stored in memory by default. When the server restarts (for deployments, crashes, or scaling events), all sessions are lost. Clients must reinitialize their sessions, which some clients may not handle gracefully. You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. +**Server restarts lose all sessions.** Sessions are stored in memory by default. When the server restarts (for deployments, crashes, or scaling events), all sessions are lost. Clients must reinitialize their sessions, which some clients may not handle gracefully. You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. -**Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. +**Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. -**No built-in backpressure on advanced features.** By default, each JSON-RPC request holds its HTTP POST open until the handler responds — providing natural HTTP/2 backpressure. However, advanced features like and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. +**No built-in backpressure on advanced features.** By default, each JSON-RPC request holds its HTTP POST open until the handler responds — providing natural HTTP/2 backpressure. However, advanced features like and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. -### stdio transport +### stdio transport -The [stdio transport](xref:transports) is inherently single-session. The client launches the server as a child process and communicates over stdin/stdout. There is exactly one session per process, the session starts when the process starts, and it ends when the process exits. +The [stdio transport](xref:transports) is inherently single-session. The client launches the server as a child process and communicates over stdin/stdout. There is exactly one session per process, the session starts when the process starts, and it ends when the process exits. -Because there is only one connection, stdio servers don't need session IDs or any explicit session management. The session is implicit in the process boundary. This makes stdio the simplest transport to use, and it naturally supports all server-to-client features (sampling, elicitation, roots) because there is always exactly one client connected. +Because there is only one connection, stdio servers don't need session IDs or any explicit session management. The session is implicit in the process boundary. This makes stdio the simplest transport to use, and it naturally supports all server-to-client features (sampling, elicitation, roots) because there is always exactly one client connected. -However, stdio servers cannot be shared between multiple clients. Each client needs its own server process. This is fine for local tool integrations (IDEs, CLI tools) but not suitable for remote or multi-tenant scenarios — use [Streamable HTTP](xref:transports) for those. For details on how DI scopes work with stdio, see [Service lifetimes and DI scopes](#service-lifetimes-and-di-scopes). +However, stdio servers cannot be shared between multiple clients. Each client needs its own server process. This is fine for local tool integrations (IDEs, CLI tools) but not suitable for remote or multi-tenant scenarios — use [Streamable HTTP](xref:transports) for those. For details on how DI scopes work with stdio, see [Service lifetimes and DI scopes](#service-lifetimes-and-di-scopes). -## Client-side session behavior +## Client-side session behavior -The SDK's MCP client () participates in sessions automatically. The **server controls session creation and destruction** — the client has no say in when a session ends. This section describes how the client manages session state, detects failures, and reconnects. +The SDK's MCP client () participates in sessions automatically. The **server controls session creation and destruction** — the client has no say in when a session ends. This section describes how the client manages session state, detects failures, and reconnects. -### Session lifecycle +### Session lifecycle -#### Joining a session +#### Joining a session -When you call , the client: +When you call , the client: -1. Connects to the server via the configured transport -2. Sends an `initialize` JSON-RPC request (without an `Mcp-Session-Id` header) -3. Receives the server's `InitializeResult` — if the response includes an `Mcp-Session-Id` header, the client stores it -4. Automatically includes the session ID in all subsequent requests (POST, GET, DELETE) +1. Connects to the server via the configured transport +2. Sends an `initialize` JSON-RPC request (without an `Mcp-Session-Id` header) +3. Receives the server's `InitializeResult` — if the response includes an `Mcp-Session-Id` header, the client stores it +4. Automatically includes the session ID in all subsequent requests (POST, GET, DELETE) -This is entirely automatic — you don't need to manage the session ID yourself. The property exposes the current session ID (or `null` for transports that don't support sessions, like stdio). +This is entirely automatic — you don't need to manage the session ID yourself. The property exposes the current session ID (or `null` for transports that don't support sessions, like stdio). -#### Session expiry +#### Session expiry -The server can terminate a session at any time — due to idle timeout, max session count exceeded, explicit shutdown, or any server-side policy. When this happens, subsequent requests with that session ID receive HTTP `404`. The client detects this and: +The server can terminate a session at any time — due to idle timeout, max session count exceeded, explicit shutdown, or any server-side policy. When this happens, subsequent requests with that session ID receive HTTP `404`. The client detects this and: -1. Wraps the failure in a with containing the HTTP status code -2. Cancels all in-flight operations -3. Completes the task +1. Wraps the failure in a with containing the HTTP status code +2. Cancels all in-flight operations +3. Completes the task -**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. +**There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. -The following example demonstrates how to detect session expiry and reconnect: +The following example demonstrates how to detect session expiry and reconnect: ```csharp -async Task ConnectWithRetryAsync( - HttpClientTransportOptions transportOptions, - HttpClient httpClient, - ILoggerFactory? loggerFactory = null, - CancellationToken cancellationToken = default) -{ - while (/* app-specific retry condition */) - { - await using var transport = new HttpClientTransport(transportOptions, httpClient, loggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: loggerFactory, cancellationToken: cancellationToken); - - // Wait for the session to end — this could be graceful disposal or server-side expiry. - var details = await client.Completion.WaitAsync(cancellationToken); - - if (details is HttpClientCompletionDetails { HttpStatusCode: System.Net.HttpStatusCode.NotFound }) - { - // The server expired our session. Create a new one. - loggerFactory?.CreateLogger("Reconnect").LogInformation( - "Session expired (404). Reconnecting with a new session..."); - continue; - } - - // For other closures (graceful disposal, fatal errors), don't retry. - return client; - } -} +async Task ConnectWithRetryAsync( + HttpClientTransportOptions transportOptions, + HttpClient httpClient, + ILoggerFactory? loggerFactory = null, + CancellationToken cancellationToken = default) +{ + while (/* app-specific retry condition */) + { + await using var transport = new HttpClientTransport(transportOptions, httpClient, loggerFactory); + var client = await McpClient.CreateAsync(transport, loggerFactory: loggerFactory, cancellationToken: cancellationToken); + + // Wait for the session to end — this could be graceful disposal or server-side expiry. + var details = await client.Completion.WaitAsync(cancellationToken); + + if (details is HttpClientCompletionDetails { HttpStatusCode: System.Net.HttpStatusCode.NotFound }) + { + // The server expired our session. Create a new one. + loggerFactory?.CreateLogger("Reconnect").LogInformation( + "Session expired (404). Reconnecting with a new session..."); + continue; + } + + // For other closures (graceful disposal, fatal errors), don't retry. + return client; + } +} ``` -#### Stream reconnection +#### Stream reconnection -The Streamable HTTP client automatically reconnects its SSE event stream when the connection drops. This only applies to **stateful sessions** — the GET event stream is how the server sends unsolicited messages to the client, and it requires an active session. Stream reconnection is separate from session expiry: reconnection recovers the event stream within an existing session, while the example above handles creating a new session after the server has terminated the old one. +The Streamable HTTP client automatically reconnects its SSE event stream when the connection drops. This only applies to **stateful sessions** — the GET event stream is how the server sends unsolicited messages to the client, and it requires an active session. Stream reconnection is separate from session expiry: reconnection recovers the event stream within an existing session, while the example above handles creating a new session after the server has terminated the old one. -If the server has an [event store](#session-resumability) configured, the client sends `Last-Event-ID` on reconnection so the server can replay missed events. See [Transports](xref:transports) for details on reconnection intervals and retry limits (, ). If all reconnection attempts are exhausted, the transport closes and `McpClient.Completion` resolves. +If the server has an [event store](#session-resumability) configured, the client sends `Last-Event-ID` on reconnection so the server can replay missed events. See [Transports](xref:transports) for details on reconnection intervals and retry limits (, ). If all reconnection attempts are exhausted, the transport closes and `McpClient.Completion` resolves. -#### Resuming a session +#### Resuming a session -If the server is still tracking the session (or supports [session migration](#session-migration)), you can reconnect without re-initializing. Save the session metadata from the original client and pass it to : +If the server is still tracking the session (or supports [session migration](#session-migration)), you can reconnect without re-initializing. Save the session metadata from the original client and pass it to : -- — set via -- -- -- (optional) -- (optional) +- — set via +- +- +- (optional) +- (optional) -See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. +See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. -Session resumption is useful when: +Session resumption is useful when: -- The client process restarts but the server session is still alive -- A transient network failure disconnects the client but the server hasn't timed out the session -- You want to hand off a session between different parts of your application +- The client process restarts but the server session is still alive +- A transient network failure disconnects the client but the server hasn't timed out the session +- You want to hand off a session between different parts of your application -#### Terminating a session +#### Terminating a session -When you dispose an `McpClient` (via `await using` or explicit `DisposeAsync`), the client sends an HTTP `DELETE` request to the session endpoint with the `Mcp-Session-Id` header. This tells the server to clean up the session immediately rather than waiting for the idle timeout. +When you dispose an `McpClient` (via `await using` or explicit `DisposeAsync`), the client sends an HTTP `DELETE` request to the session endpoint with the `Mcp-Session-Id` header. This tells the server to clean up the session immediately rather than waiting for the idle timeout. -The property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. +The property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. -### Client transport options +### Client transport options -The following properties affect client-side session behavior: +The following properties affect client-side session behavior: | Property | Default | Description | -|----------|---------|-------------| -| | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | +| --- | --- | --- | +| | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | | | `true` | Whether to send a DELETE request when the client is disposed. Set to `false` when you don't want disposal to terminate the server session. | | | `null` | Custom headers included in all requests (e.g., for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. | -For transport-level options like reconnection intervals and transport mode, see [Transports](xref:transports). +For transport-level options like reconnection intervals and transport mode, see [Transports](xref:transports). -## Server configuration +## Server configuration -### Configuration reference +### Configuration reference -All session-related configuration is on , configured via `WithHttpTransport`: +All session-related configuration is on , configured via `WithHttpTransport`: ```csharp -builder.Services.AddMcpServer() - .WithHttpTransport(options => - { - // Recommended for servers that don't need sessions. - options.Stateless = true; - - // --- Options below only apply to stateful (non-stateless) mode --- - - // How long a session can be idle before being closed (default: 2 hours) - options.IdleTimeout = TimeSpan.FromMinutes(30); - - // Maximum number of idle sessions in memory (default: 10,000) - options.MaxIdleSessionCount = 1_000; - - // Customize McpServerOptions per session with access to HttpContext - options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) => - { - // Example: customize tools based on the authenticated user's roles - var user = httpContext.User; - if (user.IsInRole("admin")) - { - mcpServerOptions.ToolCollection = [.. adminTools]; - } - }; - }); +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + // Recommended for servers that don't need sessions. + options.Stateless = true; + + // --- Options below only apply to stateful (non-stateless) mode --- + + // How long a session can be idle before being closed (default: 2 hours) + options.IdleTimeout = TimeSpan.FromMinutes(30); + + // Maximum number of idle sessions in memory (default: 10,000) + options.MaxIdleSessionCount = 1_000; + + // Customize McpServerOptions per session with access to HttpContext + options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) => + { + // Example: customize tools based on the authenticated user's roles + var user = httpContext.User; + if (user.IsInRole("admin")) + { + mcpServerOptions.ToolCollection = [.. adminTools]; + } + }; + }); ``` -### Property reference +### Property reference | Property | Type | Default | Description | -|----------|------|---------|-------------| -| | `bool` | `false` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests. | -| | `TimeSpan` | 2 hours | Duration of inactivity before a session is closed. Checked every 5 seconds. | -| | `int` | 10,000 | Maximum idle sessions before the oldest are forcibly terminated. | -| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode, this runs on every HTTP request. | +| --- | --- | --- | --- | +| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` draft revision. | +| | `TimeSpan` | 2 hours | *Stateful only (`MCP9005`).* Duration of inactivity before a session is closed. Checked every 5 seconds. | +| | `int` | 10,000 | *Stateful only (`MCP9005`).* Maximum idle sessions before the oldest are forcibly terminated. | +| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all draft-revision requests), this runs on every HTTP request. | | | `Func?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. | -| | `ISessionMigrationHandler?` | `null` | Enables cross-instance session migration. Can also be registered in DI. | -| | `ISseEventStreamStore?` | `null` | Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | -| | `bool` | `false` | Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | +| | `ISessionMigrationHandler?` | `null` | *Stateful only (`MCP9005`).* Enables cross-instance session migration. Can also be registered in DI. | +| | `ISseEventStreamStore?` | `null` | *Stateful only (`MCP9005`).* Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | +| | `bool` | `false` | *Stateful only (`MCP9005`).* Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | -### ConfigureSessionOptions +The properties marked *Stateful only* above carry diagnostic [`MCP9005`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served sessionlessly (every draft-revision request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. - is called when the server creates a new MCP server context, before the server starts processing requests. It receives the `HttpContext` from the `initialize` request, allowing you to customize the server based on the request (authentication, headers, route parameters, etc.). +### ConfigureSessionOptions -In **stateful mode**, this callback runs once per session — when the client's initial `initialize` request creates the session. + is called when the server creates a new MCP server context, before the server starts processing requests. It receives the `HttpContext` from the `initialize` request, allowing you to customize the server based on the request (authentication, headers, route parameters, etc.). + +In **stateful mode**, this callback runs once per session — when the client's initial `initialize` request creates the session. ```csharp -options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) => -{ - // Filter available tools based on a route parameter - var category = httpContext.Request.RouteValues["category"]?.ToString() ?? "all"; - mcpServerOptions.ToolCollection = GetToolsForCategory(category); - - // Set server info based on the authenticated user - var userName = httpContext.User.Identity?.Name; - mcpServerOptions.ServerInfo = new() { Name = $"MCP Server ({userName})" }; -}; +options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancellationToken) => +{ + // Filter available tools based on a route parameter + var category = httpContext.Request.RouteValues["category"]?.ToString() ?? "all"; + mcpServerOptions.ToolCollection = GetToolsForCategory(category); + + // Set server info based on the authenticated user + var userName = httpContext.User.Identity?.Name; + mcpServerOptions.ServerInfo = new() { Name = $"MCP Server ({userName})" }; +}; ``` -See the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample for a complete example that filters tools based on route parameters. +See the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample for a complete example that filters tools based on route parameters. -#### Per-request configuration in stateless mode +#### Per-request configuration in stateless mode -In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP request** because each request creates a fresh server context. This makes it useful for per-request customization based on headers, authentication, or other request-specific data — similar to middleware: +In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP request** because each request creates a fresh server context. This makes it useful for per-request customization based on headers, authentication, or other request-specific data — similar to middleware: ```csharp -builder.Services.AddMcpServer() - .WithHttpTransport(options => - { - options.Stateless = true; - options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => - { - // This runs on every request in stateless mode, so you can use the - // current HttpContext to customize tools, prompts, or resources. - var apiVersion = httpContext.Request.Headers["X-Api-Version"].ToString(); - mcpServerOptions.ToolCollection = GetToolsForVersion(apiVersion); - return Task.CompletedTask; - }; - }) - .WithTools(); +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => + { + // This runs on every request in stateless mode, so you can use the + // current HttpContext to customize tools, prompts, or resources. + var apiVersion = httpContext.Request.Headers["X-Api-Version"].ToString(); + mcpServerOptions.ToolCollection = GetToolsForVersion(apiVersion); + return Task.CompletedTask; + }; + }) + .WithTools(); ``` - -### Security and user binding -#### User binding +### Security and user binding + +#### User binding -When authentication is configured, the server automatically binds sessions to the authenticated user. This prevents one user from hijacking another user's session. +When authentication is configured, the server automatically binds sessions to the authenticated user. This prevents one user from hijacking another user's session. -##### How it works +##### How it works -1. When a session is created, the server captures the authenticated user's identity from `HttpContext.User` -2. The server extracts a user ID claim in priority order: - - `ClaimTypes.NameIdentifier` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`) - - `"sub"` (OpenID Connect subject claim) - - `ClaimTypes.Upn` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn`) -3. On each subsequent request, the server validates that the current user matches the session's original user -4. If there's a mismatch, the server responds with `403 Forbidden` +1. When a session is created, the server captures the authenticated user's identity from `HttpContext.User` +2. The server extracts a user ID claim in priority order: -This binding is automatic — no configuration is needed. If no authentication middleware is configured, user binding is skipped (the session is not bound to any user). + - `ClaimTypes.NameIdentifier` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`) + - `"sub"` (OpenID Connect subject claim) + - `ClaimTypes.Upn` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn`) -## Service lifetimes and DI scopes +3. On each subsequent request, the server validates that the current user matches the session's original user +4. If there's a mismatch, the server responds with `403 Forbidden` -How the server resolves scoped services depends on the transport and session mode. The property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. +This binding is automatic — no configuration is needed. If no authentication middleware is configured, user binding is skipped (the session is not bound to any user). -### Stateful HTTP +## Service lifetimes and DI scopes -In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. +How the server resolves scoped services depends on the transport and session mode. The property controls whether the server creates a new `IServiceProvider` scope for each handler invocation. -This means: +### Stateful HTTP -- **Scoped services** are created fresh for each handler invocation and disposed when the handler completes -- **Singleton services** resolve from the application container as usual -- **Transient services** create a new instance per resolution, as usual +In stateful mode, the server's is the application-level `IServiceProvider` — not a per-request scope. Because the server outlives individual HTTP requests, defaults to `true`: each handler invocation (tool call, resource read, etc.) creates a new scope. -### Stateless HTTP +This means: -In stateless mode, the server uses ASP.NET Core's per-request `HttpContext.RequestServices` as its service provider, and is automatically set to `false`. No additional scopes are created — handlers share the same HTTP request scope that middleware and other ASP.NET Core components use. +- **Scoped services** are created fresh for each handler invocation and disposed when the handler completes +- **Singleton services** resolve from the application container as usual +- **Transient services** create a new instance per resolution, as usual -This means: +### Stateless HTTP -- **Scoped services** behave exactly like any other ASP.NET Core request-scoped service — middleware can set state on a scoped service and the tool handler will see it -- The DI lifetime model is identical to a standard ASP.NET Core controller or minimal API endpoint +In stateless mode, the server uses ASP.NET Core's per-request `HttpContext.RequestServices` as its service provider, and is automatically set to `false`. No additional scopes are created — handlers share the same HTTP request scope that middleware and other ASP.NET Core components use. -### stdio +This means: -The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. +- **Scoped services** behave exactly like any other ASP.NET Core request-scoped service — middleware can set state on a scoped service and the tool handler will see it +- The DI lifetime model is identical to a standard ASP.NET Core controller or minimal API endpoint -### McpServer.Create (custom transports) +### stdio -When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/blob/51a4fde4d9cfa12ef9430deef7daeaac36625be8/samples/InMemoryTransport/Program.cs#L6-L14) shows a minimal example of using `McpServer.Create` with in-memory pipes: +The stdio transport creates a single server for the lifetime of the process. The server's is the application-level `IServiceProvider`. By default, is `true`, so each handler invocation gets its own scope — the same behavior as stateful HTTP. + +### McpServer.Create (custom transports) + +When you create a server directly with , you control the `IServiceProvider` and transport yourself. If you pass an already-scoped provider, you can set to `false` to avoid creating redundant nested scopes. The [InMemoryTransport sample](https://github.com/modelcontextprotocol/csharp-sdk/blob/51a4fde4d9cfa12ef9430deef7daeaac36625be8/samples/InMemoryTransport/Program.cs#L6-L14) shows a minimal example of using `McpServer.Create` with in-memory pipes: ```csharp -Pipe clientToServerPipe = new(), serverToClientPipe = new(); - -await using var scope = serviceProvider.CreateAsyncScope(); - -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - ScopeRequests = false, // The scope is already managed externally. - ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })] - }, - serviceProvider: scope.ServiceProvider); +Pipe clientToServerPipe = new(), serverToClientPipe = new(); + +await using var scope = serviceProvider.CreateAsyncScope(); + +await using McpServer server = McpServer.Create( + new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), + new McpServerOptions + { + ScopeRequests = false, // The scope is already managed externally. + ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })] + }, + serviceProvider: scope.ServiceProvider); ``` -### DI scope summary +### DI scope summary | Mode | Service provider | ScopeRequests | Handler scope | -|------|-----------------|---------------|---------------| +| --- | --- | --- | --- | | **Stateful HTTP** | Application services | `true` (default) | New scope per handler invocation | | **Stateless HTTP** | `HttpContext.RequestServices` | `false` (forced) | Shared HTTP request scope | | **stdio** | Application services | `true` (default, configurable) | New scope per handler invocation | | **McpServer.Create** | Caller-provided | Caller-controlled | Depends on `ScopeRequests` and whether the provider is already scoped | -## Cancellation and disposal +## Cancellation and disposal -Every tool, prompt, and resource handler can receive a `CancellationToken`. The source and behavior of that token depends on the transport and session mode. The SDK also supports the MCP [cancellation protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) for client-initiated cancellation of individual requests. +Every tool, prompt, and resource handler can receive a `CancellationToken`. The source and behavior of that token depends on the transport and session mode. The SDK also supports the MCP [cancellation protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) for client-initiated cancellation of individual requests. -### Handler cancellation tokens +### Handler cancellation tokens | Mode | Token source | Cancelled when | -|------|-------------|----------------| +| --- | --- | --- | | **Stateless HTTP** | `HttpContext.RequestAborted` | Client disconnects, or ASP.NET Core shuts down. Identical to a standard minimal API or controller action. | | **Stateful Streamable HTTP** | Linked token: HTTP request + application shutdown + session disposal | Client disconnects, `ApplicationStopping` fires, or the session is terminated (idle timeout, DELETE, max idle count). | | **SSE (legacy)** | Linked token: GET request + application shutdown | Client disconnects the SSE stream, or `ApplicationStopping` fires. The entire session terminates with the GET stream. | | **stdio** | Token passed to `McpServer.RunAsync()` | stdin EOF (client process exits), or the token is cancelled (e.g., host shutdown via Ctrl+C). | -Stateless mode has the simplest cancellation story: the handler's `CancellationToken` is `HttpContext.RequestAborted` — the same token any ASP.NET Core endpoint receives. No additional tokens, linked sources, or session-level lifecycle to reason about. +Stateless mode has the simplest cancellation story: the handler's `CancellationToken` is `HttpContext.RequestAborted` — the same token any ASP.NET Core endpoint receives. No additional tokens, linked sources, or session-level lifecycle to reason about. -### Client-initiated cancellation +### Client-initiated cancellation -In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific in-flight request by sending a [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) notification with the request ID. The SDK looks up the running handler and cancels its `CancellationToken`. This may result in an `OperationCanceledException` if the handler is awaiting a cancellation-aware operation when the token is cancelled. +In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific in-flight request by sending a [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) notification with the request ID. The SDK looks up the running handler and cancels its `CancellationToken`. This may result in an `OperationCanceledException` if the handler is awaiting a cancellation-aware operation when the token is cancelled. -- Invalid or unknown request IDs are silently ignored -- In stateless mode, there is no persistent session to receive the notification on, so client-initiated cancellation does not apply -- For [task-augmented requests](xref:tasks), the MCP specification requires using [`tasks/cancel`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#cancelling-tasks) instead of `notifications/cancelled`. The SDK uses a separate cancellation token per task (independent of the original HTTP request), so `tasks/cancel` can cancel a task even after the initial request has completed. See [Tasks and session modes](#tasks-and-session-modes) for details. +- Invalid or unknown request IDs are silently ignored +- In stateless mode, there is no persistent session to receive the notification on, so client-initiated cancellation does not apply +- For [task-augmented requests](xref:tasks), the MCP specification requires using [`tasks/cancel`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#cancelling-tasks) instead of `notifications/cancelled`. The SDK uses a separate cancellation token per task (independent of the original HTTP request), so `tasks/cancel` can cancel a task even after the initial request has completed. See [Tasks and session modes](#tasks-and-session-modes) for details. -### Server and session disposal +### Server and session disposal -When an `McpServer` is disposed — whether due to session termination, transport closure, or application shutdown — the SDK **awaits all in-flight handlers** before `DisposeAsync()` returns. This means: +When an `McpServer` is disposed — whether due to session termination, transport closure, or application shutdown — the SDK **awaits all in-flight handlers** before `DisposeAsync()` returns. This means: -- Handlers have an opportunity to complete cleanup (e.g., flushing writes, releasing locks) -- Scoped services created for the handler are disposed after the handler completes -- The SDK logs each handler's completion at `Information` level, including elapsed time +- Handlers have an opportunity to complete cleanup (e.g., flushing writes, releasing locks) +- Scoped services created for the handler are disposed after the handler completes +- The SDK logs each handler's completion at `Information` level, including elapsed time -#### Graceful shutdown in ASP.NET Core +#### Graceful shutdown in ASP.NET Core -When `ApplicationStopping` fires (e.g., `SIGTERM`, `Ctrl+C`, `app.StopAsync()`), the SDK immediately cancels active SSE and GET streams so that connected clients don't block shutdown. In-flight POST request handlers continue running and are awaited before the server finishes disposing. The total shutdown time is bounded by ASP.NET Core's `HostOptions.ShutdownTimeout` (default: **30 seconds**). In practice, the SDK completes shutdown well within this limit. +When `ApplicationStopping` fires (e.g., `SIGTERM`, `Ctrl+C`, `app.StopAsync()`), the SDK immediately cancels active SSE and GET streams so that connected clients don't block shutdown. In-flight POST request handlers continue running and are awaited before the server finishes disposing. The total shutdown time is bounded by ASP.NET Core's `HostOptions.ShutdownTimeout` (default: **30 seconds**). In practice, the SDK completes shutdown well within this limit. -For stateless servers, shutdown is even simpler: each request is independent, so there are no long-lived sessions to drain — just standard ASP.NET Core request completion. +For stateless servers, shutdown is even simpler: each request is independent, so there are no long-lived sessions to drain — just standard ASP.NET Core request completion. -#### stdio process lifecycle +#### stdio process lifecycle -- **Graceful shutdown** (stdin EOF, `SIGTERM`, `Ctrl+C`): The transport closes, in-flight handlers are awaited, and `McpServer.DisposeAsync()` runs normally. -- **Process kill** (`SIGKILL`): No cleanup occurs. Handlers are interrupted mid-execution, and no disposal code runs. This is inherent to process-level termination and not specific to the SDK. +- **Graceful shutdown** (stdin EOF, `SIGTERM`, `Ctrl+C`): The transport closes, in-flight handlers are awaited, and `McpServer.DisposeAsync()` runs normally. +- **Process kill** (`SIGKILL`): No cleanup occurs. Handlers are interrupted mid-execution, and no disposal code runs. This is inherent to process-level termination and not specific to the SDK. -### Stateless per-request logging +### Stateless per-request logging -In stateless mode, each HTTP request creates and disposes a short-lived `McpServer` instance. This produces session lifecycle log entries at `Trace` level (`session created` / `session disposed`) for every request. These are typically invisible at default log levels but may appear when troubleshooting with verbose logging enabled. There is no user-facing `initialize` handshake in stateless mode — the SDK handles the per-request server lifecycle internally. +In stateless mode, each HTTP request creates and disposes a short-lived `McpServer` instance. This produces session lifecycle log entries at `Trace` level (`session created` / `session disposed`) for every request. These are typically invisible at default log levels but may appear when troubleshooting with verbose logging enabled. There is no user-facing `initialize` handshake in stateless mode — the SDK handles the per-request server lifecycle internally. -## Tasks and session modes +## Tasks and session modes -[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. -### Stateless mode +### Stateless mode -Tasks are a natural fit for stateless servers. The client sends a task-augmented `tools/call` request, receives a task ID immediately, and polls for completion with `tasks/get` or `tasks/result` on subsequent independent HTTP requests. Because each request creates an ephemeral `McpServer` that shares the same `IMcpTaskStore`, all task operations work without any persistent session. +Tasks are a natural fit for stateless servers. The client sends a task-augmented `tools/call` request, receives a task ID immediately, and polls for completion with `tasks/get` or `tasks/result` on subsequent independent HTTP requests. Because each request creates an ephemeral `McpServer` that shares the same `IMcpTaskStore`, all task operations work without any persistent session. -In stateless mode, there is no `SessionId`, so the task store does not apply session-based isolation. All tasks are accessible from any request to the same server. This is typically fine for single-purpose servers or when authentication middleware already identifies the caller. +In stateless mode, there is no `SessionId`, so the task store does not apply session-based isolation. All tasks are accessible from any request to the same server. This is typically fine for single-purpose servers or when authentication middleware already identifies the caller. -### Stateful mode +### Stateful mode -In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. +In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. -Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. +Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. -### Task cancellation vs request cancellation +### Task cancellation vs request cancellation -The MCP specification defines two distinct cancellation mechanisms: +The MCP specification defines two distinct cancellation mechanisms: -- **`notifications/cancelled`** cancels a regular in-flight request by its JSON-RPC request ID. The SDK looks up the handler's `CancellationToken` and cancels it. This is a fire-and-forget notification with no response. -- **`tasks/cancel`** cancels a task by its task ID. The SDK signals a separate per-task `CancellationToken` (independent of the original request) and updates the task's status to `cancelled` in the store. This is a request-response operation that returns the final task state. +- **`notifications/cancelled`** cancels a regular in-flight request by its JSON-RPC request ID. The SDK looks up the handler's `CancellationToken` and cancels it. This is a fire-and-forget notification with no response. +- **`tasks/cancel`** cancels a task by its task ID. The SDK signals a separate per-task `CancellationToken` (independent of the original request) and updates the task's status to `cancelled` in the store. This is a request-response operation that returns the final task state. -For task-augmented requests, the specification [requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) using `tasks/cancel` instead of `notifications/cancelled`. +For task-augmented requests, the specification [requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) using `tasks/cancel` instead of `notifications/cancelled`. -## Request backpressure +## Request backpressure -How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. **In the default configuration, stateful and stateless modes provide identical HTTP-level backpressure** — both hold the POST response open while the handler runs, so HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits concurrent handlers per connection. The unbounded cases (legacy SSE, `EventStreamStore`, Tasks) are all **opt-in** advanced features. +How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. **In the default configuration, stateful and stateless modes provide identical HTTP-level backpressure** — both hold the POST response open while the handler runs, so HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits concurrent handlers per connection. The unbounded cases (legacy SSE, `EventStreamStore`, Tasks) are all **opt-in** advanced features. -### Default stateful mode (no EventStreamStore, no tasks) +### Default stateful mode (no EventStreamStore, no tasks) -In the default configuration, each JSON-RPC request holds its POST response open until the handler produces a result. The POST response body is an SSE stream that carries the JSON-RPC response, and the server awaits the handler's completion before closing it. This means: +In the default configuration, each JSON-RPC request holds its POST response open until the handler produces a result. The POST response body is an SSE stream that carries the JSON-RPC response, and the server awaits the handler's completion before closing it. This means: -- Each in-flight handler occupies one HTTP/2 stream -- The HTTP server's `MaxStreamsPerConnection` (default: **100** in Kestrel) limits concurrent handlers per connection -- This is the same backpressure model as **gRPC unary calls** — one request occupies one stream until the response is sent +- Each in-flight handler occupies one HTTP/2 stream +- The HTTP server's `MaxStreamsPerConnection` (default: **100** in Kestrel) limits concurrent handlers per connection +- This is the same backpressure model as **gRPC unary calls** — one request occupies one stream until the response is sent -One difference from gRPC: handler cancellation tokens are linked to the **session** lifetime, not `HttpContext.RequestAborted`. If a client disconnects from a POST mid-flight, the handler continues running until it completes or the session is terminated. But the client has freed a stream slot, so it can submit a new request — meaning the server could accumulate up to `MaxStreamsPerConnection` handlers that outlive their original connections. In practice this is bounded and comparable to how gRPC handlers behave when the client cancels an RPC. +One difference from gRPC: handler cancellation tokens are linked to the **session** lifetime, not `HttpContext.RequestAborted`. If a client disconnects from a POST mid-flight, the handler continues running until it completes or the session is terminated. But the client has freed a stream slot, so it can submit a new request — meaning the server could accumulate up to `MaxStreamsPerConnection` handlers that outlive their original connections. In practice this is bounded and comparable to how gRPC handlers behave when the client cancels an RPC. -For comparison, ASP.NET Core SignalR limits concurrent hub invocations per client to **1** by default (`MaximumParallelInvocationsPerClient`). Default stateful MCP is less restrictive but still bounded by HTTP/2 stream limits. +For comparison, ASP.NET Core SignalR limits concurrent hub invocations per client to **1** by default (`MaximumParallelInvocationsPerClient`). Default stateful MCP is less restrictive but still bounded by HTTP/2 stream limits. -### SSE (legacy — opt-in only) +### SSE (legacy — opt-in only) -Legacy SSE endpoints are [disabled by default](#legacy-sse-transport) and must be explicitly enabled via . This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure. +Legacy SSE endpoints are [disabled by default](#legacy-sse-transport) and must be explicitly enabled via . This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure. -The legacy SSE transport separates the request and response channels: clients POST JSON-RPC messages to `/message` and receive responses through a long-lived GET SSE stream on `/sse`. The POST endpoint returns **202 Accepted immediately** after queuing the message — it does not wait for the handler to complete. This means there is **no HTTP-level backpressure** on handler concurrency, because each POST frees its connection immediately regardless of how long the handler runs. +The legacy SSE transport separates the request and response channels: clients POST JSON-RPC messages to `/message` and receive responses through a long-lived GET SSE stream on `/sse`. The POST endpoint returns **202 Accepted immediately** after queuing the message — it does not wait for the handler to complete. This means there is **no HTTP-level backpressure** on handler concurrency, because each POST frees its connection immediately regardless of how long the handler runs. -Internally, handlers are dispatched with the same fire-and-forget pattern as Streamable HTTP (`_ = ProcessMessageAsync()`). A client can send unlimited POST requests to `/message` while keeping the GET stream open, and each one spawns a concurrent handler with no built-in limit. +Internally, handlers are dispatched with the same fire-and-forget pattern as Streamable HTTP (`_ = ProcessMessageAsync()`). A client can send unlimited POST requests to `/message` while keeping the GET stream open, and each one spawns a concurrent handler with no built-in limit. -The GET stream does provide **session lifetime bounds**: handler cancellation tokens are linked to the GET request's `HttpContext.RequestAborted`, so when the client disconnects the SSE stream, all in-flight handlers are cancelled. This is similar to SignalR's connection-bound lifetime model — but unlike SignalR, there is no per-client concurrency limit like `MaximumParallelInvocationsPerClient`. The GET stream provides cleanup on disconnect, not rate-limiting during the connection. +The GET stream does provide **session lifetime bounds**: handler cancellation tokens are linked to the GET request's `HttpContext.RequestAborted`, so when the client disconnects the SSE stream, all in-flight handlers are cancelled. This is similar to SignalR's connection-bound lifetime model — but unlike SignalR, there is no per-client concurrency limit like `MaximumParallelInvocationsPerClient`. The GET stream provides cleanup on disconnect, not rate-limiting during the connection. -### With EventStreamStore +### With EventStreamStore - is an advanced API that enables session resumability — storing SSE events so clients can reconnect and replay missed messages using the `Last-Event-ID` header. When configured, handlers gain the ability to call `EnablePollingAsync()`, which closes the POST response early and switches the client to polling mode. + is an advanced API that enables session resumability — storing SSE events so clients can reconnect and replay missed messages using the `Last-Event-ID` header. When configured, handlers gain the ability to call `EnablePollingAsync()`, which closes the POST response early and switches the client to polling mode. -When a handler calls `EnablePollingAsync()`: +When a handler calls `EnablePollingAsync()`: -- The POST response completes **before the handler finishes** -- The handler continues running in the background, decoupled from any HTTP request -- The client's HTTP/2 stream slot is freed, allowing it to submit more requests -- **HTTP-level backpressure no longer applies** — there is no built-in limit on how many concurrent handlers can accumulate +- The POST response completes **before the handler finishes** +- The handler continues running in the background, decoupled from any HTTP request +- The client's HTTP/2 stream slot is freed, allowing it to submit more requests +- **HTTP-level backpressure no longer applies** — there is no built-in limit on how many concurrent handlers can accumulate -The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expiration, 30-minute sliding window) that govern event retention, but these do not limit handler concurrency. If you enable `EventStreamStore` on a public-facing server, apply **HTTP rate-limiting middleware** and **reverse proxy limits** to compensate for the loss of stream-level backpressure. +The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expiration, 30-minute sliding window) that govern event retention, but these do not limit handler concurrency. If you enable `EventStreamStore` on a public-facing server, apply **HTTP rate-limiting middleware** and **reverse proxy limits** to compensate for the loss of stream-level backpressure. -### With tasks (experimental) +### With tasks (experimental) -[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. +[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. -This means: +This means: -- **No HTTP-level backpressure on task handlers** — each POST returns almost immediately, freeing the stream slot -- A client can rapidly submit many task-augmented requests, each spawning a background handler with no concurrency limit -- Task cleanup is governed by TTL (time-to-live), not by handler completion or session termination +- **No HTTP-level backpressure on task handlers** — each POST returns almost immediately, freeing the stream slot +- A client can rapidly submit many task-augmented requests, each spawning a background handler with no concurrency limit +- Task cleanup is governed by TTL (time-to-live), not by handler completion or session termination -Tasks are a natural fit for **stateless deployments at scale**, where the `IMcpTaskStore` is backed by an external store (database, distributed cache) and the client polls `tasks/get` independently. In this model, work distribution and concurrency control are handled by your infrastructure (job queues, worker pools) rather than by HTTP stream limits. +Tasks are a natural fit for **stateless deployments at scale**, where the `IMcpTaskStore` is backed by an external store (database, distributed cache) and the client polls `tasks/get` independently. In this model, work distribution and concurrency control are handled by your infrastructure (job queues, worker pools) rather than by HTTP stream limits. -For servers using the built-in automatic task handlers without external work distribution, apply the same rate-limiting and reverse-proxy protections recommended for `EventStreamStore` deployments. +For servers using the built-in automatic task handlers without external work distribution, apply the same rate-limiting and reverse-proxy protections recommended for `EventStreamStore` deployments. -### Stateless mode +### Stateless mode -Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above). +Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above). -### Summary +### Summary | Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | -|---|---|---|---| +| --- | --- | --- | --- | | **Stateless** | Yes (handler = request) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) | | **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) | | **SSE (legacy — opt-in)** | No (returns 202 Accepted) | None built-in; GET stream provides cleanup | Unbounded — apply rate limiting | | **Stateful + EventStreamStore** | No (if `EnablePollingAsync()` called) | None built-in | Unbounded — apply rate limiting | | **Stateful + Tasks** | No (returns task ID immediately) | None built-in | Unbounded — apply rate limiting | -## Observability +## Observability -The SDK's tracing and metrics work in **all modes** — stateful, stateless, and stdio — and do not depend on sessions. Distributed tracing is purely request-scoped: [W3C trace context](https://www.w3.org/TR/trace-context/) (`traceparent` / `tracestate`) propagates through the `_meta` field in JSON-RPC messages, so a client's tool call and the server's handling appear as parent-child spans regardless of transport or session mode. +The SDK's tracing and metrics work in **all modes** — stateful, stateless, and stdio — and do not depend on sessions. Distributed tracing is purely request-scoped: [W3C trace context](https://www.w3.org/TR/trace-context/) (`traceparent` / `tracestate`) propagates through the `_meta` field in JSON-RPC messages, so a client's tool call and the server's handling appear as parent-child spans regardless of transport or session mode. -### The `mcp.session.id` activity tag +### The `mcp.session.id` activity tag -Every request `Activity` is tagged with `mcp.session.id` — a unique identifier generated independently by each and instance. **Despite the name, this is not the transport session ID** (`Mcp-Session-Id` header). It is a per-instance GUID that tracks the lifetime of that specific client or server object. +Every request `Activity` is tagged with `mcp.session.id` — a unique identifier generated independently by each and instance. **Despite the name, this is not the transport session ID** (`Mcp-Session-Id` header). It is a per-instance GUID that tracks the lifetime of that specific client or server object. -- **Stateful mode**: The server's `mcp.session.id` is stable for the lifetime of the session. This makes it useful for correlating all operations handled by a single long-lived `McpServer` instance — you can filter your observability platform to see every tool call, notification, and request within one session. -- **Stateless mode**: Each HTTP request creates a new `McpServer` instance with its own `mcp.session.id`, so the tag effectively identifies individual requests. This is simpler — the HTTP request's own `Activity` is the natural parent, and there's no long-lived session to correlate. -- The client and server always have **different** `mcp.session.id` values, even when they share the same transport session ID. +- **Stateful mode**: The server's `mcp.session.id` is stable for the lifetime of the session. This makes it useful for correlating all operations handled by a single long-lived `McpServer` instance — you can filter your observability platform to see every tool call, notification, and request within one session. +- **Stateless mode**: Each HTTP request creates a new `McpServer` instance with its own `mcp.session.id`, so the tag effectively identifies individual requests. This is simpler — the HTTP request's own `Activity` is the natural parent, and there's no long-lived session to correlate. +- The client and server always have **different** `mcp.session.id` values, even when they share the same transport session ID. -### Correlating with the transport session ID +### Correlating with the transport session ID -The transport session ID (, the `Mcp-Session-Id` header value) and the `mcp.session.id` activity tag are not automatically correlated by the SDK. You can bridge this gap by tagging the ASP.NET Core request `Activity` with the transport session ID using an [endpoint filter](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/min-api-filters) on `MapMcp()`: +The transport session ID (, the `Mcp-Session-Id` header value) and the `mcp.session.id` activity tag are not automatically correlated by the SDK. You can bridge this gap by tagging the ASP.NET Core request `Activity` with the transport session ID using an [endpoint filter](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/min-api-filters) on `MapMcp()`: ```csharp -app.MapMcp().AddEndpointFilter(async (context, next) => -{ - var httpContext = context.HttpContext; - - // The session ID is available in the request header on all non-initialize requests - // in stateful mode (the client echoes back the ID it received from the server's - // initialize response). It is null for the first initialize request and always null - // in stateless mode. Tag before next() so child spans inherit the value. - string? sessionId = httpContext.Request.Headers["Mcp-Session-Id"]; - if (sessionId != null) - { - Activity.Current?.AddTag("mcp.transport.session.id", sessionId); - } - - return await next(context); -}); +app.MapMcp().AddEndpointFilter(async (context, next) => +{ + var httpContext = context.HttpContext; + + // The session ID is available in the request header on all non-initialize requests + // in stateful mode (the client echoes back the ID it received from the server's + // initialize response). It is null for the first initialize request and always null + // in stateless mode. Tag before next() so child spans inherit the value. + string? sessionId = httpContext.Request.Headers["Mcp-Session-Id"]; + if (sessionId != null) + { + Activity.Current?.AddTag("mcp.transport.session.id", sessionId); + } + + return await next(context); +}); ``` - -> [!NOTE] -> The tag is added **before** calling `next()` so that any child activities created during request processing inherit it. The trade-off is that the very first `initialize` request won't have the tag, because the client doesn't have a session ID yet — the server assigns it in the response. All subsequent requests will have it. + + +> [!NOTE] +> The tag is added **before** calling `next()` so that any child activities created during request processing inherit it. The trade-off is that the very first `initialize` request won't have the tag, because the client doesn't have a session ID yet — the server assigns it in the response. All subsequent requests will have it. + + - -> [!NOTE] -> The `AllowNewSessionForNonInitializeRequests` AppContext switch (`ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests`) is a back-compat escape hatch that allows creating new sessions from non-initialize POST requests that arrive without an `Mcp-Session-Id` header. When enabled, the server creates a **brand-new session** for each such request rather than rejecting it — the response still carries the `Mcp-Session-Id` header with the new session's ID. This is **non-compliant with the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)**, which requires that only `initialize` requests create sessions. Use it only as a temporary workaround for clients that don't implement the session protocol correctly. +> [!NOTE] +> The `AllowNewSessionForNonInitializeRequests` AppContext switch (`ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests`) is a back-compat escape hatch that allows creating new sessions from non-initialize POST requests that arrive without an `Mcp-Session-Id` header. When enabled, the server creates a **brand-new session** for each such request rather than rejecting it — the response still carries the `Mcp-Session-Id` header with the new session's ID. This is \*\*non-compliant with the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)\*\*, which requires that only `initialize` requests create sessions. Use it only as a temporary workaround for clients that don't implement the session protocol correctly. -### Other activity tags +### Other activity tags -Other tags include `mcp.method.name`, `mcp.protocol.version`, `jsonrpc.request.id`, and operation-specific tags like `gen_ai.tool.name` for tool calls. Use these to filter and group traces in your observability platform (Jaeger, Zipkin, Application Insights, etc.). +Other tags include `mcp.method.name`, `mcp.protocol.version`, `jsonrpc.request.id`, and operation-specific tags like `gen_ai.tool.name` for tool calls. Use these to filter and group traces in your observability platform (Jaeger, Zipkin, Application Insights, etc.). -### Metrics +### Metrics -The SDK records histograms under the `Experimental.ModelContextProtocol` meter: +The SDK records histograms under the `Experimental.ModelContextProtocol` meter: | Metric | Description | -|--------|-------------| +| --- | --- | | `mcp.server.session.duration` | Duration of the MCP session on the server | | `mcp.client.session.duration` | Duration of the MCP session on the client | | `mcp.server.operation.duration` | Duration of each request/notification on the server | | `mcp.client.operation.duration` | Duration of each request/notification on the client | -In stateless mode, each HTTP request is its own "session", so `mcp.server.session.duration` measures individual request lifetimes rather than long-lived session durations. +In stateless mode, each HTTP request is its own "session", so `mcp.server.session.duration` measures individual request lifetimes rather than long-lived session durations. -## Legacy SSE transport +## Legacy SSE transport -The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](#request-backpressure). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9004`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. +The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](#request-backpressure). To enable them, set to `true` — this property is marked \`[Obsolete] `with a diagnostic warning (`MCP9004`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the` ModelContextProtocol.AspNetCore.EnableLegacySse\` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. -> [!NOTE] -> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. +> [!NOTE] +> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the GET and POST requests. -### How SSE sessions work +### How SSE sessions work -1. The client connects to the `/sse` endpoint with a GET request -2. The server generates a session ID and sends a `/message?sessionId={id}` URL as the first SSE event -3. The client sends JSON-RPC messages as POST requests to that `/message?sessionId={id}` URL -4. The server streams responses and unsolicited messages back over the open SSE GET stream +1. The client connects to the `/sse` endpoint with a GET request +2. The server generates a session ID and sends a `/message?sessionId={id}` URL as the first SSE event +3. The client sends JSON-RPC messages as POST requests to that `/message?sessionId={id}` URL +4. The server streams responses and unsolicited messages back over the open SSE GET stream -Unlike Streamable HTTP which uses the `Mcp-Session-Id` header, legacy SSE passes the session ID as a query string parameter on the `/message` endpoint. +Unlike Streamable HTTP which uses the `Mcp-Session-Id` header, legacy SSE passes the session ID as a query string parameter on the `/message` endpoint. -### Session lifetime +### Session lifetime -SSE session lifetime is tied directly to the GET SSE stream. When the client disconnects (detected via `HttpContext.RequestAborted`), or the server shuts down (via `IHostApplicationLifetime.ApplicationStopping`), the session is immediately removed. There is no idle timeout or maximum idle session count for SSE sessions — the session exists exactly as long as the SSE connection is open. +SSE session lifetime is tied directly to the GET SSE stream. When the client disconnects (detected via `HttpContext.RequestAborted`), or the server shuts down (via `IHostApplicationLifetime.ApplicationStopping`), the session is immediately removed. There is no idle timeout or maximum idle session count for SSE sessions — the session exists exactly as long as the SSE connection is open. -This makes SSE sessions behave similarly to [stdio](#stdio-transport): the session is implicit in the connection lifetime, and disconnection is the only termination mechanism. +This makes SSE sessions behave similarly to [stdio](#stdio-transport): the session is implicit in the connection lifetime, and disconnection is the only termination mechanism. -### Configuration +### Configuration - and both work with SSE sessions. They are called during the `/sse` GET request handler, and services resolve from the GET request's `HttpContext.RequestServices`. [User binding](#user-binding) also works — the authenticated user is captured from the GET request and verified on each POST to `/message`. + and both work with SSE sessions. They are called during the `/sse` GET request handler, and services resolve from the GET request's `HttpContext.RequestServices`. [User binding](#user-binding) also works — the authenticated user is captured from the GET request and verified on each POST to `/message`. -## Advanced features +## Advanced features -### Session migration +### Session migration -For high-availability deployments, enables session migration across server instances. When a request arrives with a session ID that isn't found locally, the handler is consulted to attempt migration. +For high-availability deployments, enables session migration across server instances. When a request arrives with a session ID that isn't found locally, the handler is consulted to attempt migration. ```csharp -builder.Services.AddMcpServer() - .WithHttpTransport(options => - { - // Session migration is a stateful-mode feature. - options.Stateless = false; - options.SessionMigrationHandler = new MySessionMigrationHandler(); - }); +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + // Session migration is a stateful-mode feature. + options.Stateless = false; + options.SessionMigrationHandler = new MySessionMigrationHandler(); + }); ``` -You can also register the handler in DI: +You can also register the handler in DI: ```csharp -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); ``` -Implementations should: +Implementations should: -- Validate that the request is authorized (check `HttpContext.User`) -- Reconstruct the session state from external storage (database, distributed cache, etc.) -- Return `McpServerOptions` pre-populated with `KnownClientInfo` and `KnownClientCapabilities` to skip re-initialization +- Validate that the request is authorized (check `HttpContext.User`) +- Reconstruct the session state from external storage (database, distributed cache, etc.) +- Return `McpServerOptions` pre-populated with `KnownClientInfo` and `KnownClientCapabilities` to skip re-initialization -Session migration adds significant complexity. Consider whether stateless mode is a better fit for your deployment scenario. +Session migration adds significant complexity. Consider whether stateless mode is a better fit for your deployment scenario. -### Session resumability +### Session resumability -The server can store SSE events for replay when clients reconnect using the `Last-Event-ID` header. Configure this with : +The server can store SSE events for replay when clients reconnect using the `Last-Event-ID` header. Configure this with : ```csharp -builder.Services.AddMcpServer() - .WithHttpTransport(options => - { - // Session resumability is a stateful-mode feature. - options.Stateless = false; - options.EventStreamStore = new MyEventStreamStore(); - }); +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + // Session resumability is a stateful-mode feature. + options.Stateless = false; + options.EventStreamStore = new MyEventStreamStore(); + }); ``` -When configured: +When configured: -- The server generates unique event IDs for each SSE message -- Events are stored for later replay -- When a client reconnects with `Last-Event-ID`, missed events are replayed before new events are sent +- The server generates unique event IDs for each SSE message +- Events are stored for later replay +- When a client reconnects with `Last-Event-ID`, missed events are replayed before new events are sent -This is useful for clients that may experience transient network issues. Without an event store, clients that disconnect and reconnect may miss events that were sent while they were disconnected. +This is useful for clients that may experience transient network issues. Without an event store, clients that disconnect and reconnect may miss events that were sent while they were disconnected. \ No newline at end of file diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 4936f4e5d..ae552e32c 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -339,7 +339,7 @@ Rules and constraints: - The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`). - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. -- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `2026-07-28` and later). ### Pre-loading tool definitions on the client diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 26a44bd78..938f017ba 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -38,3 +38,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | +| `MCP9005` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. The draft protocol revision (`2026-07-28`) is sessionless, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | diff --git a/package-lock.json b/package-lock.json index 521815617..77ce83884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "csharp-sdk", + "name": "halter73-expert-train", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,18 +23,18 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.16", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz", - "integrity": "sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==", + "version": "0.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.2.tgz", + "integrity": "sha512-/8bde9d0mfsvgd9IwQgNIl1AS9uNOp/+ZG+2nNRWXtPs6xrz/cNp4ObBMmGY9kP8dkDaF3bvjtC/2Hj8TStMRg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", "commander": "^14.0.2", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "jose": "^6.1.2", - "undici": "^7.19.0", + "undici": "^7.25.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -602,9 +602,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1428,9 +1428,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index dd8dedfe3..21d33001c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 0768cb442..ae5c84d6f 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -30,7 +30,7 @@ internal static class McpHttpHeaders /// The associated helpers perform exact ordinal matches against this single value rather /// than any ordered comparison. /// - public const string DraftProtocolVersion = "DRAFT-2026-v1"; + public const string DraftProtocolVersion = "2026-07-28"; /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 46ea782d8..bf95e3abb 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -33,4 +33,8 @@ internal static class Obsoletions public const string EnableLegacySse_DiagnosticId = "MCP9004"; public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead."; public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + + public const string LegacyStatefulHttp_DiagnosticId = "MCP9005"; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 648cb86df..8a09be900 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -50,7 +50,9 @@ public class HttpServerTransportOptions /// allowing for load balancing without session affinity. /// /// - /// if the server runs in a stateless mode; if the server tracks state between requests. The default is . + /// if the server runs in a stateless mode; if the server tracks state between requests. + /// The default is as of the 2026-07-28 draft protocol revision (SEP-2567); + /// set to only when you need to support legacy clients that rely on session affinity. /// /// /// If , will be null, and the "MCP-Session-Id" header will not be used, @@ -58,8 +60,13 @@ public class HttpServerTransportOptions /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses /// might arrive at another ASP.NET Core application process. /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. + /// + /// Requests that declare the 2026-07-28 draft protocol revision via the MCP-Protocol-Version header + /// are always routed through the stateless path regardless of this property's value, because that revision + /// removes Mcp-Session-Id entirely (SEP-2567). + /// /// - public bool Stateless { get; set; } + public bool Stateless { get; set; } = true; /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -112,6 +119,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISseEventStreamStore? EventStreamStore { get; set; } /// @@ -128,6 +136,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISessionMigrationHandler? SessionMigrationHandler { get; set; } /// @@ -144,6 +153,7 @@ public class HttpServerTransportOptions /// Enabling a per-session can be useful for setting variables /// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers. /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public bool PerSessionExecutionContext { get; set; } /// @@ -162,6 +172,7 @@ public class HttpServerTransportOptions /// tied to the open GET /sse request, and they are removed immediately when the client disconnects. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2); /// @@ -182,6 +193,7 @@ public class HttpServerTransportOptions /// exactly as long as the SSE connection is open. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public int MaxIdleSessionCount { get; set; } = 10_000; /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs index b4ce545f8..00b03e4d2 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs @@ -12,7 +12,9 @@ internal sealed class HttpServerTransportOptionsSetup(IServiceProvider servicePr { public void Configure(HttpServerTransportOptions options) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. options.EventStreamStore ??= serviceProvider.GetService(); options.SessionMigrationHandler ??= serviceProvider.GetService(); +#pragma warning restore MCP9005 } } diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index 645253d6f..439226b6a 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -18,12 +18,14 @@ public IdleTrackingBackgroundService( ILogger logger) { // Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown. +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan) { ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero); } ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0); +#pragma warning restore MCP9005 _sessions = sessions; _options = options; diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs index 880bd04a5..da542b804 100644 --- a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs +++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs @@ -17,9 +17,11 @@ internal sealed partial class StatefulSessionManager( private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider; +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout; private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider); private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount; +#pragma warning restore MCP9005 private readonly object _idlePruningLock = new(); private readonly List _idleTimestamps = []; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ad4930e80..5d0f73434 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -12,6 +12,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Security.Cryptography; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -39,7 +40,7 @@ internal sealed class StreamableHttpHandler( "2025-03-26", "2025-06-18", "2025-11-25", - "DRAFT-2026-v1", + McpHttpHeaders.DraftProtocolVersion, ]; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); @@ -54,9 +55,9 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } @@ -82,7 +83,7 @@ await WriteJsonRpcErrorAsync(context, return; } - if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out errorMessage)) + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); return; @@ -108,9 +109,24 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); + return; + } + + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft protocol revision (SEP-2575), the standalone HTTP GET endpoint for unsolicited + // server-to-client messages is removed. Clients should use subscriptions/listen (POST) instead. + // We only reject GET when the request looks like a draft-mode probe (experimental version with + // no Mcp-Session-Id); legacy stateful sessions that opted into MRTR via the experimental version + // are still allowed to use GET for back-compat. + if (IsDraftProtocolRequest(context) && string.IsNullOrEmpty(sessionId)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead.", + StatusCodes.Status400BadRequest); return; } @@ -122,7 +138,6 @@ await WriteJsonRpcErrorAsync(context, return; } - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); var session = await GetSessionAsync(context, sessionId); if (session is null) { @@ -211,13 +226,25 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft revision there are no sessions to terminate. Reject DELETE requests that + // declare the draft version without an Mcp-Session-Id. Legacy stateful sessions opted into + // the draft version may still call DELETE for back-compat. + if (IsDraftProtocolRequest(context) && string.IsNullOrEmpty(sessionId)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (no Mcp-Session-Id sessions exist).", + StatusCodes.Status400BadRequest); + return; + } + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) { return; @@ -280,10 +307,12 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask TryMigrateSessionAsync(HttpContext context, string sessionId) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler) { return null; } +#pragma warning restore MCP9005 var migrationLock = _migrationLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); await migrationLock.WaitAsync(context.RequestAborted); @@ -319,6 +348,22 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + bool isDraftRequest = IsDraftProtocolRequest(context); + + // Under the draft protocol revision (SEP-2575 + SEP-2567), sessions are removed entirely. + // A request that declares the experimental draft version via MCP-Protocol-Version and that + // does NOT include an Mcp-Session-Id is treated as sessionless regardless of the + // HttpServerTransportOptions.Stateless setting (which governs only legacy clients). + // + // For back-compat with clients that previously used the experimental version on top of the + // legacy stateful session model (e.g., MRTR-as-extension-on-initialize), we still route + // experimental-version requests that DO include an Mcp-Session-Id through the legacy session + // lookup path. SEP-2567 will eventually phase that out, but we preserve it now to avoid + // breaking existing consumers without forcing them to change their setup code. + if (isDraftRequest && string.IsNullOrEmpty(sessionId)) + { + return await StartNewSessionAsync(context, forceStateless: true); + } if (string.IsNullOrEmpty(sessionId)) { @@ -350,14 +395,28 @@ await WriteJsonRpcErrorAsync(context, } } - private async ValueTask StartNewSessionAsync(HttpContext context) + /// + /// Returns when the request declares the draft protocol revision via + /// the MCP-Protocol-Version header. Draft requests are always sessionless and do not perform + /// the legacy initialize handshake (SEP-2575 + SEP-2567). + /// + private static bool IsDraftProtocolRequest(HttpContext context) + { + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + return string.Equals(protocolVersionHeader, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + } + + private async ValueTask StartNewSessionAsync(HttpContext context, bool forceStateless = false) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + bool isStateless = HttpServerTransportOptions.Stateless || forceStateless; + + if (!isStateless) { sessionId = MakeNewSessionId(); +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. transport = new(loggerFactory) { SessionId = sessionId, @@ -367,12 +426,13 @@ private async ValueTask StartNewSessionAsync(HttpContext ? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct) : null, }; +#pragma warning restore MCP9005 context.Response.Headers[McpSessionIdHeaderName] = sessionId; } else { - // In stateless mode, each request is independent. Don't set any session ID on the transport. + // In stateless mode (legacy or draft), each request is independent. Don't set any session ID on the transport. // If in the future we support resuming stateless requests, we should populate // the event stream store and retry interval here as well. sessionId = ""; @@ -382,22 +442,25 @@ private async ValueTask StartNewSessionAsync(HttpContext }; } - return await CreateSessionAsync(context, transport, sessionId); + return await CreateSessionAsync(context, transport, sessionId, forceStateless: forceStateless); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, - Action? configureOptions = null) + Action? configureOptions = null, + bool forceStateless = false) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + bool effectivelyStateless = HttpServerTransportOptions.Stateless || forceStateless; + + if (effectivelyStateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (effectivelyStateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -434,8 +497,10 @@ private async ValueTask MigrateSessionAsync( var transport = new StreamableHttpServerTransport(loggerFactory) { SessionId = sessionId, +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext, EventStreamStore = HttpServerTransportOptions.EventStreamStore, +#pragma warning restore MCP9005 }; // Initialize the transport with the migrated session's init params. @@ -452,7 +517,9 @@ private async ValueTask MigrateSessionAsync( private async ValueTask GetEventStreamReaderAsync(HttpContext context, string lastEventId) { +#pragma warning disable MCP9005 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore) +#pragma warning restore MCP9005 { await WriteJsonRpcErrorAsync(context, "Bad Request: This server does not support resuming streams.", @@ -559,19 +626,32 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility, - /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. + /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the + /// rejection uses the error code with a data payload + /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { - errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.", + Data = JsonSerializer.SerializeToNode( + new UnsupportedProtocolVersionErrorData + { + Supported = [.. s_supportedProtocolVersions], + Requested = protocolVersionHeader, + }, + GetRequiredJsonTypeInfo()), + }; return false; } - errorMessage = null; + errorDetail = null; return true; } @@ -997,6 +1077,12 @@ private static SafeIntegerParse ParseSafeInteger(string text, out long value) return SafeIntegerParse.NotNumeric; } + private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode) + { + var jsonRpcError = new JsonRpcError { Error = detail }; + return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 209d644d2..09d580979 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Net; +using System.Net.Http; +using System.Text.Json; using System.Threading.Channels; namespace ModelContextProtocol.Client; @@ -62,6 +64,7 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can { // Try StreamableHttp first var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory); + McpProtocolException? structuredError = null; try { @@ -73,22 +76,76 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; } + else if (await TryGetJsonRpcErrorFromResponseAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) + { + // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server + // — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion, + // -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other + // application-level error). Don't fall back to SSE — that would mask the real signal + // and surface a misleading "session id required" error from the SSE GET path. + // Adopt the Streamable HTTP transport and surface the structured exception to the + // caller so the connect-time fallback logic can react per spec PR #2844. + LogUsingStreamableHttp(_name); + ActiveTransport = streamableHttpTransport; + structuredError = McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } else { - // If the status code is not success, fall back to SSE + // Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this + // is an older deployment that expects the SSE transport (which establishes its + // protocol via GET /sse rather than POST). Fall back to SSE per the original + // behavior. LogStreamableHttpFailed(_name, response.StatusCode); await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false); } } - catch + catch when (ActiveTransport is null) { - // If nothing threw inside the try block, we've either set streamableHttpTransport as the - // ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block. + // Only dispose the Streamable HTTP transport when we didn't adopt it. If we set + // ActiveTransport above (success path OR structured-error path), the transport's + // lifetime is owned by the outer transport from this point on. await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); throw; } + + if (structuredError is not null) + { + throw structuredError; + } + } + + private static async Task TryGetJsonRpcErrorFromResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentType?.MediaType != "application/json") + { + return null; + } + + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + return null; + } + + if (string.IsNullOrEmpty(body)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage) as JsonRpcError; + } + catch + { + return null; + } } private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 894ca6945..f08fbf7bf 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -289,55 +289,140 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - // Send initialize request - string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; - var initializeResponse = await SendRequestAsync( - RequestMethods.Initialize, - new InitializeRequestParams - { - ProtocolVersion = requestProtocol, - Capabilities = _options.Capabilities ?? new ClientCapabilities(), - ClientInfo = _options.ClientInfo ?? DefaultImplementation, - Meta = _options.InitializeMeta, - }, - McpJsonUtilities.JsonContext.Default.InitializeRequestParams, - McpJsonUtilities.JsonContext.Default.InitializeResult, - cancellationToken: initializationCts.Token).ConfigureAwait(false); - - // Store server information - if (_logger.IsEnabled(LogLevel.Information)) + // Under the draft protocol revision (SEP-2575), there is no initialize handshake. + // Instead, the client calls server/discover to learn the server's capabilities and + // then begins sending normal RPCs that carry protocolVersion / clientInfo / + // clientCapabilities in their per-request _meta. + if (_options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion) { - LogServerCapabilitiesReceived(_endpointName, - capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); - } + string draftVersion = McpSessionHandler.DraftProtocolVersion; + + // Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as + // a draft client when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = draftVersion; + _sessionHandler.NegotiatedProtocolVersion = draftVersion; + + DiscoverResult? discoverResult = null; + bool fallbackToLegacy = false; + IList? serverSupportedVersions = null; + + // Apply a probe timeout so dual-era clients don't block forever waiting for a + // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // The probe timeout is bounded by InitializationTimeout, but we cap it at 5s so we + // can quickly fall back when a server isn't going to respond. + var probeTimeout = TimeSpan.FromSeconds(5); + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); + if (_options.InitializationTimeout > probeTimeout) + { + probeCts.CancelAfter(probeTimeout); + } + + try + { + discoverResult = await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: probeCts.Token).ConfigureAwait(false); + } + catch (UnsupportedProtocolVersionException ex) + { + // Spec-recognized modern-server signal: -32004 with data.supported[]. The server is + // modern but doesn't speak our preferred version. Retry with a mutually supported + // version from data.supported[] instead of falling back to legacy initialize. + fallbackToLegacy = true; + serverSupportedVersions = (IList)ex.Supported; + } + catch (MissingRequiredClientCapabilityException) + { + // Spec-recognized modern-server signal: -32003. The server is modern but rejected + // our capability set. Surface as-is (no fallback): the user must add capabilities. + throw; + } + catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) + { + // Spec-recognized modern-server signal: -32001. The server is modern but rejected + // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match + // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no + // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + throw; + } + catch (McpProtocolException) + { + // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code — + // any non-modern JSON-RPC error from the probe indicates a legacy server. + // Common causes include MethodNotFound from a server that has no + // server/discover handler, InvalidParams from a server confused by the + // SEP-2575 _meta envelope, ParseError from a server that can't handle our + // payload shape, or any other transport-defined error. The three modern-server + // signals (-32004 UnsupportedProtocolVersion, -32003 + // MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and + // never reach here. + fallbackToLegacy = true; + } + catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) + { + // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no + // response within a reasonable timeout means the server is legacy. Fall back. + fallbackToLegacy = true; + } - _serverCapabilities = initializeResponse.Capabilities; - _serverInfo = initializeResponse.ServerInfo; - _serverInstructions = initializeResponse.Instructions; + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion)) + { + // Server is reachable and supports server/discover, but doesn't support the + // experimental version. Fall back to legacy initialize with the highest + // mutually-supported version from supportedVersions[]. + fallbackToLegacy = true; + serverSupportedVersions = discoverResult.SupportedVersions; + } - // Validate protocol version - bool isResponseProtocolValid = - _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); - if (!isResponseProtocolValid) + if (fallbackToLegacy) + { + // Reset negotiated state and try legacy initialize. + _negotiatedProtocolVersion = null; + _sessionHandler.NegotiatedProtocolVersion = null; + + string fallbackVersion = serverSupportedVersions? + .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault() + ?? McpSessionHandler.LatestProtocolVersion; + + // Honor MinProtocolVersion: refuse to fall back below the configured minimum. + // String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions. + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0) + { + throw new McpException( + $"Server does not support the configured minimum protocol version '{minVersion}'. " + + (serverSupportedVersions is null + ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); + } + + await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + } + else + { + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + + _serverCapabilities = discoverResult!.Capabilities; + _serverInfo = discoverResult.ServerInfo; + _serverInstructions = discoverResult.Instructions; + } + } + else { - LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); - throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); + // Legacy initialize handshake. + string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } - - _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; - - // Update session handler with the negotiated protocol version for telemetry - _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; - - // Send initialized notification - await this.SendNotificationAsync( - NotificationMethods.InitializedNotification, - new InitializedNotificationParams(), - McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, - cancellationToken: initializationCts.Token).ConfigureAwait(false); - } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -355,6 +440,75 @@ await this.SendNotificationAsync( LogClientConnected(_endpointName); } + /// + /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// records the negotiated protocol version, and stores the server capabilities/info/instructions. + /// + private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + { + var initializeResponse = await SendRequestAsync( + RequestMethods.Initialize, + new InitializeRequestParams + { + ProtocolVersion = requestProtocol, + Capabilities = _options.Capabilities ?? new ClientCapabilities(), + ClientInfo = _options.ClientInfo ?? DefaultImplementation, + Meta = _options.InitializeMeta, + }, + McpJsonUtilities.JsonContext.Default.InitializeRequestParams, + McpJsonUtilities.JsonContext.Default.InitializeResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + + _serverCapabilities = initializeResponse.Capabilities; + _serverInfo = initializeResponse.ServerInfo; + _serverInstructions = initializeResponse.Instructions; + + // When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST + // respect it. When the user pinned the draft version but we fell back (e.g., legacy server + // rejected server/discover), or when no version was pinned, accept any supported response. + // This is the spec-mandated behavior: a draft client must be able to downgrade to whatever + // legacy version the server advertises. + bool isResponseProtocolValid; + if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion) + { + isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; + } + else + { + isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + } + if (!isResponseProtocolValid) + { + LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); + throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); + } + + // If the user set a MinProtocolVersion, also enforce it against the negotiated response + // (the server could have downgraded further than the version we asked for). + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0) + { + throw new McpException( + $"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'."); + } + + _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; + + await this.SendNotificationAsync( + NotificationMethods.InitializedNotification, + new InitializedNotificationParams(), + McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + /// /// Configures the client to use an already initialized session without performing the handshake. /// @@ -467,6 +621,8 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && const int maxRetries = 10; + InjectDraftMetaIfNeeded(request); + for (int attempt = 0; attempt <= maxRetries; attempt++) { JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); @@ -504,6 +660,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && } request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + InjectDraftMetaIfNeeded(request); } else if (inputRequiredResult.RequestState is not null) { @@ -513,10 +670,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; - } - else - { - throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); + InjectDraftMetaIfNeeded(request); } continue; // retry with the updated request @@ -528,6 +682,39 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times."); } + /// + /// Injects the draft-protocol per-request _meta fields (protocol version, client info, + /// client capabilities) into the request when this client is using the draft protocol revision + /// (SEP-2575). No-op for legacy clients. + /// + private void InjectDraftMetaIfNeeded(JsonRpcRequest request) + { + if (!IsDraftProtocol()) + { + return; + } + + // Initialize is never sent under the draft revision, but guard defensively in case a caller + // routes it through here (e.g., during back-compat fallback negotiation). + if (request.Method == RequestMethods.Initialize) + { + return; + } + + McpSessionHandler.InjectDraftMeta( + request, + _negotiatedProtocolVersion!, + _options.ClientInfo ?? DefaultImplementation, + _options.Capabilities ?? new ClientCapabilities()); + } + + /// + /// Returns when the negotiated protocol version is the draft revision + /// (SEP-2575 + SEP-2567 + MRTR). + /// + internal bool IsDraftProtocol() => + _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; + /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => _sessionHandler.SendMessageAsync(message, cancellationToken); diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 1e3bdc4bf..775343c28 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -64,6 +64,37 @@ public sealed class McpClientOptions /// public string? ProtocolVersion { get; set; } + /// + /// Gets or sets the minimum protocol version the client will accept during version negotiation. + /// + /// + /// + /// When negotiating with a server that advertises multiple supported versions, or when falling back + /// to a legacy server, the client will refuse any version older than this minimum and surface an + /// instead. + /// + /// + /// This is useful when the client requires features (such as the draft revision's removal of the + /// initialize handshake or Mcp-Session-Id) that are not available in older protocol + /// revisions. Setting this to disables the + /// automatic legacy-server fallback that otherwise switches to the initialize handshake. + /// + /// + /// If (the default), the client falls back to any version the server + /// advertises, including legacy versions such as 2025-11-25. + /// + /// + /// + /// var clientOptions = new McpClientOptions + /// { + /// ProtocolVersion = McpSession.DraftProtocolVersion, + /// MinProtocolVersion = McpSession.DraftProtocolVersion, + /// }; + /// + /// + /// + public string? MinProtocolVersion { get; set; } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 2cebccb3b..e13745f97 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -63,9 +63,69 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation { // Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it. using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false); + + // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a + // JSON-RPC error envelope means the peer is signalling something application-level about + // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the + // connect-time logic can react — for example, the three modern draft-protocol error codes + // (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability, + // -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from + // legacy servers that don't understand the draft _meta envelope) become generic + // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue + // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. + // The three modern draft-protocol error codes are also surfaced for non-400 status codes + // for robustness — servers occasionally emit them with 4xx codes other than 400. + if (!response.IsSuccessStatusCode && + response.Content.Headers.ContentType?.MediaType == "application/json") + { + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + body = string.Empty; + } + + if (!string.IsNullOrEmpty(body) && + TryParseJsonRpcError(body, out var parsedError) && + (response.StatusCode == HttpStatusCode.BadRequest || + IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code))) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + } + await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } + private static bool IsModernDraftErrorCode(McpErrorCode code) => + code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; + + private static bool TryParseJsonRpcError(string body, out JsonRpcError parsedError) + { + try + { + var message = JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + if (message is JsonRpcError rpcError) + { + parsedError = rpcError; + return true; + } + } + catch + { + // Not a valid JSON-RPC error response — fall through to the standard HTTP exception path. + } + + parsedError = null!; + return false; + } + // This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception. internal async Task SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken) { @@ -79,6 +139,12 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes LogTransportSendingMessageSensitive(message); + // Under the draft protocol revision (SEP-2575), every request carries its protocol version in + // _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP + // header). Pick the value off the message so the first draft request (server/discover) can + // include the header even before we've recorded a negotiated version from an initialize reply. + var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion; + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token); cancellationToken = sendCts.Token; @@ -90,7 +156,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes }, }; - CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); + CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, protocolVersionForRequest); AddMcpRequestHeaders(httpRequestMessage.Headers, message); @@ -156,10 +222,35 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); } + else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) + { + // Under the draft protocol revision (SEP-2575), server/discover replaces the initialize + // handshake. The transport caches the protocol version from the outgoing request's _meta + // so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing. + _negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message); + } return response; } + /// + /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, + /// introduced by the draft protocol revision (SEP-2575). Returns for messages that + /// don't have that field. + /// + private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message) + { + if (message is JsonRpcRequest { Params: System.Text.Json.Nodes.JsonObject paramsObj } && + paramsObj["_meta"] is System.Text.Json.Nodes.JsonObject metaObj && + metaObj[NotificationMethods.ProtocolVersionMetaKey] is System.Text.Json.Nodes.JsonValue versionValue && + versionValue.TryGetValue(out string? version)) + { + return version; + } + + return null; + } + public override async ValueTask DisposeAsync() { using var _ = await _disposeLock.LockAsync().ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 54b9eeebf..74f9110bb 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -43,6 +43,31 @@ public enum McpErrorCode /// ResourceNotFound = -32002, + /// + /// Indicates that a request requires a client capability that was not declared in the request's + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// requiredCapabilities object describing the capabilities the server requires from the client + /// to process the request. For HTTP, the response status code is 400 Bad Request. + /// + /// + MissingRequiredClientCapability = -32003, + + /// + /// Indicates that the request's declared protocol version is not supported by the server. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// supported array of protocol version strings the server supports and the original + /// requested protocol version. For HTTP, the response status code is 400 Bad Request. + /// + /// + UnsupportedProtocolVersion = -32004, + /// /// Indicates that URL-mode elicitation is required to complete the requested operation. /// diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 7006d7221..f8109bae5 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -123,8 +123,14 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(CompleteResult))] [JsonSerializable(typeof(CreateMessageRequestParams))] [JsonSerializable(typeof(CreateMessageResult))] + [JsonSerializable(typeof(DiscoverRequestParams))] + [JsonSerializable(typeof(DiscoverResult))] [JsonSerializable(typeof(ElicitRequestParams))] [JsonSerializable(typeof(ElicitResult))] + [JsonSerializable(typeof(MissingRequiredClientCapabilityErrorData))] + [JsonSerializable(typeof(SubscriptionsListenRequestParams))] + [JsonSerializable(typeof(SubscriptionsAcknowledgedNotificationParams))] + [JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))] [JsonSerializable(typeof(UrlElicitationRequiredErrorData))] [JsonSerializable(typeof(EmptyResult))] [JsonSerializable(typeof(GetPromptRequestParams))] @@ -145,6 +151,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(PingResult))] [JsonSerializable(typeof(ReadResourceRequestParams))] [JsonSerializable(typeof(ReadResourceResult))] + [JsonSerializable(typeof(CacheScope))] [JsonSerializable(typeof(SetLevelRequestParams))] [JsonSerializable(typeof(SubscribeRequestParams))] [JsonSerializable(typeof(UnsubscribeRequestParams))] @@ -189,6 +196,11 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(ProgressToken))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(Implementation))] + [JsonSerializable(typeof(ClientCapabilities))] + [JsonSerializable(typeof(ServerCapabilities))] + [JsonSerializable(typeof(LoggingLevel))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 73d99da71..a7a735d7f 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,6 +28,26 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { + /// The latest stable protocol revision this SDK supports. + /// + /// Set or + /// to this value to explicitly pin to the current stable revision instead of accepting whatever + /// the runtime negotiates. + /// + public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion; + + /// The in-progress draft protocol revision this SDK supports. + /// + /// Setting or + /// to this value opts the session into the draft revision. The draft revision removes the + /// initialize handshake (SEP-2575) and the Mcp-Session-Id header (SEP-2567), so a draft + /// HTTP server is sessionless on the wire regardless of HttpServerTransportOptions.Stateless. + /// Clients automatically fall back to the legacy initialize flow when the server does not + /// support the draft revision; set to this value + /// to disable that fallback. + /// + public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion; + /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index e874e6724..d404a95cb 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -36,7 +36,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// Clients and servers opt in by setting /// or to this value. /// - internal const string DraftProtocolVersion = "DRAFT-2026-v1"; + internal const string DraftProtocolVersion = "2026-07-28"; /// /// All protocol versions supported by this implementation. @@ -141,10 +141,25 @@ public McpSessionHandler( _outgoingMessageFilter = outgoingMessageFilter ?? (next => next); _logger = logger; - // Per the MCP spec, ping may be initiated by either party and must always be handled. + // ping was removed in the draft protocol revision (SEP-2575). Under draft, return + // MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult. + // Liveness on draft sessions belongs to transport- and request-level timeouts, not a + // dedicated MCP RPC. _requestHandlers.Set( RequestMethods.Ping, - (request, _, cancellationToken) => new ValueTask(new PingResult()), + (request, jsonRpcRequest, cancellationToken) => + { + string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (perRequestVersion is not null && + StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0) + { + throw new McpProtocolException( + $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", + McpErrorCode.MethodNotFound); + } + + return new ValueTask(new PingResult()); + }, McpJsonUtilities.JsonContext.Default.JsonNode, McpJsonUtilities.JsonContext.Default.PingResult); @@ -263,6 +278,18 @@ ex is OperationCanceledException && Message = urlException.Message, Data = urlException.CreateErrorDataNode(), }, + UnsupportedProtocolVersionException upvException => new() + { + Code = (int)upvException.ErrorCode, + Message = upvException.Message, + Data = upvException.CreateErrorDataNode(), + }, + MissingRequiredClientCapabilityException mrccException => new() + { + Code = (int)mrccException.ErrorCode, + Message = mrccException.Message, + Data = mrccException.CreateErrorDataNode(), + }, McpProtocolException mcpProtocolException => new() { Code = (int)mcpProtocolException.ErrorCode, @@ -371,6 +398,14 @@ private static async Task GetCompletionDetailsAsync(Tas private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { + // Project the draft-protocol per-request _meta fields onto the message context before any + // filters run so they (and downstream handlers) can read client info / capabilities / + // protocol version / log level without re-parsing. + if (_isServer && message is JsonRpcRequest incomingRequest) + { + PopulateContextFromMeta(incomingRequest); + } + Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -506,6 +541,104 @@ await SendMessageAsync(new JsonRpcResponse return result; } + /// + /// Reads the draft-protocol per-request _meta fields off the request and projects them onto + /// so they're available without re-parsing throughout the pipeline. + /// + /// + /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, + /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field + /// that's already set on the context (e.g., + /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone + /// unless explicitly overwritten by a non-null value parsed here. + /// + internal static void PopulateContextFromMeta(JsonRpcRequest request) + { + if (request.Params is not JsonObject paramsObj) + { + return; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + return; + } + + var context = request.Context ??= new JsonRpcMessageContext(); + + if (metaObj[NotificationMethods.ProtocolVersionMetaKey] is JsonValue protocolVersion && + protocolVersion.TryGetValue(out string? protocolVersionValue)) + { + // If a transport-level header already populated this, validate it matches per SEP-2575. + if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Protocol version mismatch: the per-request _meta value '{protocolVersionValue}' does not match the transport-level header value '{existing}'.", + McpErrorCode.InvalidParams); + } + + context.ProtocolVersion = protocolVersionValue; + } + + if (metaObj[NotificationMethods.ClientInfoMetaKey] is JsonNode clientInfoNode) + { + context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); + } + + if (metaObj[NotificationMethods.ClientCapabilitiesMetaKey] is JsonNode clientCapabilitiesNode) + { + context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + } + + if (metaObj[NotificationMethods.LogLevelMetaKey] is JsonNode logLevelNode) + { + context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + } + + /// + /// Injects the draft-protocol per-request _meta fields into an outgoing request, + /// idempotently overwriting any existing values. + /// + /// + /// Used by in draft mode to carry protocol version, client info, and + /// client capabilities on every outgoing request (replacing what the legacy initialize handshake + /// previously negotiated once). + /// + internal static void InjectDraftMeta( + JsonRpcRequest request, + string protocolVersion, + Implementation clientInfo, + ClientCapabilities clientCapabilities, + LoggingLevel? logLevel = null) + { + var paramsObj = request.Params as JsonObject; + if (paramsObj is null) + { + paramsObj = new JsonObject(); + request.Params = paramsObj; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + metaObj = new JsonObject(); + paramsObj["_meta"] = metaObj; + } + + metaObj[NotificationMethods.ProtocolVersionMetaKey] = protocolVersion; + metaObj[NotificationMethods.ClientInfoMetaKey] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation); + metaObj[NotificationMethods.ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + + if (logLevel is { } level) + { + metaObj[NotificationMethods.LogLevelMetaKey] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + else + { + metaObj.Remove(NotificationMethods.LogLevelMetaKey); + } + } + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) @@ -994,6 +1127,17 @@ private static TimeSpan GetElapsed(long startingTimestamp) => } private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error) + => CreateRemoteProtocolExceptionFromError(error); + + /// + /// Creates a typed from a JSON-RPC error response. + /// + /// + /// Exposed internally so transports that surface an HTTP-level error containing a JSON-RPC error + /// body (e.g., a 400 with ) can convert + /// the error to the same typed exception that JSON-RPC-level error responses produce. + /// + internal static McpProtocolException CreateRemoteProtocolExceptionFromError(JsonRpcError error) { string formattedMessage = $"Request failed (remote): {error.Error.Message}"; var errorCode = (McpErrorCode)error.Error.Code; @@ -1004,6 +1148,16 @@ private static McpProtocolException CreateRemoteProtocolException(JsonRpcError e { exception = urlException; } + else if (errorCode == McpErrorCode.UnsupportedProtocolVersion && + UnsupportedProtocolVersionException.TryCreateFromError(formattedMessage, error.Error, out var upvException)) + { + exception = upvException; + } + else if (errorCode == McpErrorCode.MissingRequiredClientCapability && + MissingRequiredClientCapabilityException.TryCreateFromError(formattedMessage, error.Error, out var mrccException)) + { + exception = mrccException; + } else { exception = new McpProtocolException(formattedMessage, errorCode); diff --git a/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs new file mode 100644 index 000000000..aca6d4902 --- /dev/null +++ b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request requires a client capability that was not declared +/// in the request's per-request _meta/io.modelcontextprotocol/clientCapabilities field. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when a handler cannot +/// proceed because the client did not declare a required capability for the request. The exception is converted +/// to a JSON-RPC error response with code (-32003) +/// and a payload. +/// +public sealed class MissingRequiredClientCapabilityException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The capabilities the server requires for the request. + /// A human-readable description of the error. If , a default message is used. + public MissingRequiredClientCapabilityException(ClientCapabilities requiredCapabilities, string? message = null) + : base(message ?? "The request requires client capabilities that were not declared in _meta/clientCapabilities.", + McpErrorCode.MissingRequiredClientCapability) + { + Throw.IfNull(requiredCapabilities); + RequiredCapabilities = requiredCapabilities; + } + + /// Gets the client capabilities required for the request. + public ClientCapabilities RequiredCapabilities { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = RequiredCapabilities, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out MissingRequiredClientCapabilityException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData); + if (payload?.RequiredCapabilities is null) + { + return false; + } + + exception = new MissingRequiredClientCapabilityException(payload.RequiredCapabilities, formattedMessage); + return true; + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScope.cs b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs new file mode 100644 index 000000000..d87cdd7f9 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Indicates the intended scope of a cached response, analogous to the HTTP +/// Cache-Control: public and Cache-Control: private directives. +/// +/// +/// +/// This is used by to control who may cache a +/// response returned by tools/list, prompts/list, resources/list, +/// resources/templates/list, and resources/read. +/// +/// +/// When the field is absent from a response, clients should treat it as . +/// +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CacheScope +{ + /// + /// The response does not contain user-specific data. Any client, shared gateway, or caching + /// proxy may store and serve the cached response to any user. + /// + /// + /// This is appropriate for lists of tools, prompts, and resource templates that are identical + /// for all users. + /// + [JsonStringEnumMemberName("public")] + Public, + + /// + /// The response contains user-specific data. Only the requesting user's client may cache it. + /// Shared caches (for example, multi-tenant gateways) must not serve the cached response to a + /// different user. + /// + /// + /// This is appropriate for resources/read results that depend on the authenticated user, + /// or for filtered list results that vary per user. + /// + [JsonStringEnumMemberName("private")] + Private +} diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs new file mode 100644 index 000000000..ef61df263 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Serializes caching-scope hints, tolerating unknown or future values on read. +/// +/// +/// +/// SEP-2549 introduces cacheScope as a forward-looking caching hint. If a server sends an +/// unrecognized scope string (for example, a value added in a later revision of the specification) or a +/// non-string token, this converter maps it to rather than throwing. This prevents +/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole +/// tool list). A result is the same as an absent field, which clients treat as +/// . +/// +/// +/// This converter is applied per-property on the cacheable result types. The +/// enum itself retains a standard string converter for any standalone serialization. +/// +/// +internal sealed class CacheScopeConverter : JsonConverter +{ + public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + string? value = reader.GetString(); + + // Match case-insensitively so a non-conforming casing of "private" (a security-relevant hint) + // is honored rather than falling through to null, which clients would treat as "public" and + // could cache user-specific data in a shared cache. Genuinely unknown values still map to null. + if (string.Equals(value, "public", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Public; + } + + if (string.Equals(value, "private", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Private; + } + + return null; + } + + // Any non-string token (number, bool, object, array) is an unrecognized hint. Consume the whole + // value, including the contents of an object or array, so the reader is left correctly positioned + // before mapping to null. Skipping is required for container tokens: returning without consuming + // them would leave the reader mispositioned and break deserialization of the enclosing result. + reader.Skip(); + return null; + } + + public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value switch + { + CacheScope.Public => "public", + CacheScope.Private => "private", + _ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."), + }); + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs new file mode 100644 index 000000000..e9a343f46 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// The discover RPC takes no payload of its own. Per-request metadata +/// (protocol version, client info, client capabilities) flows through the +/// inherited property under the +/// io.modelcontextprotocol/* keys defined by the draft protocol revision (SEP-2575). +/// +/// +public sealed class DiscoverRequestParams : RequestParams +{ +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs new file mode 100644 index 000000000..7a4e75453 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result returned from a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575) as the canonical way for a client +/// to learn what a server supports without performing the legacy initialize handshake. +/// +/// +public sealed class DiscoverResult : Result, ICacheableResult +{ + /// + /// Gets or sets the list of MCP protocol version strings that the server supports. + /// + /// + /// The client should choose a version from this list for use in subsequent requests. + /// + [JsonPropertyName("supportedVersions")] + public required IList SupportedVersions { get; set; } + + /// + /// Gets or sets the capabilities of the server. + /// + [JsonPropertyName("capabilities")] + public required ServerCapabilities Capabilities { get; set; } + + /// + /// Gets or sets information about the server implementation. + /// + [JsonPropertyName("serverInfo")] + public required Implementation ServerInfo { get; set; } + + /// + /// Gets or sets optional instructions describing how to use the server and its features. + /// + /// + /// This can be used by clients to improve an LLM's understanding of the server, + /// for example by including it in a system prompt. + /// + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } + + /// + /// + /// Spec PR #2855 makes ttlMs a required field on . The + /// server emits a safe default (, i.e. immediately stale) on + /// draft sessions when the application has not set an explicit value, preserving today's + /// "do not cache" behavior while satisfying the wire requirement. + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + /// + /// Spec PR #2855 makes cacheScope a required field on . The + /// server emits a safe default () on draft sessions + /// when the application has not set an explicit value. + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs new file mode 100644 index 000000000..8b4986a3a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -0,0 +1,59 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a result that carries time-to-live (TTL) caching hints, allowing clients to cache +/// the response for a period of time before re-fetching. +/// +/// +/// +/// This interface corresponds to the CacheableResult type in the Model Context Protocol +/// schema and is implemented by the results of server/discover, tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. +/// +/// +/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing +/// list_changed and resources/updated notification mechanisms; both can coexist. A +/// relevant notification invalidates a cached response regardless of any remaining TTL. +/// +/// +public interface ICacheableResult +{ + /// + /// Gets or sets a hint indicating how long the client may cache this response before re-fetching. + /// + /// + /// + /// The semantics are analogous to the HTTP Cache-Control: max-age directive. The value is + /// serialized as an integer number of milliseconds under the ttlMs JSON property. + /// + /// + /// A value of indicates the response should be considered immediately + /// stale; a positive value indicates the client should consider the response fresh for that + /// duration from the time it was received. + /// + /// + /// When this property is (the field was absent from the response), clients + /// should assume a default of (immediately stale) and rely on their + /// own caching heuristics or notifications. A negative value should likewise be treated as + /// . + /// + /// + TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the intended scope of the cached response. + /// + /// + /// + /// When this property is (the field was absent from the response), clients + /// should treat the response as . + /// + /// + /// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and + /// surfaced as rather than causing deserialization of the whole result to + /// fail, so a single unexpected hint never prevents a client from reading the result. + /// + /// + CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 1dfef5de1..4bbe22ac2 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -200,6 +200,19 @@ public sealed class Converter : JsonConverter throw new JsonException("Response must have either result or error"); } + if (error is not null) + { + // Per JSON-RPC 2.0, when an error occurs before the request id can be determined + // (e.g. parse error or invalid request), the server MUST respond with id=null. + // Accept null-id error responses so callers can recognize the structured signal + // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code). + return new JsonRpcError + { + Id = id, + Error = error + }; + } + // Error: Messages with neither id nor method are invalid throw new JsonException("Invalid JSON-RPC message format"); } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index e5c0f3931..b804c288a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -85,4 +85,35 @@ public sealed class JsonRpcMessageContext /// to flow the protocol version header so the server can determine client capabilities. /// public string? ProtocolVersion { get; set; } + + /// + /// Gets or sets the client info derived from the per-request + /// _meta/io.modelcontextprotocol/clientInfo field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). When the request was made under the draft revision, + /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// + public Implementation? ClientInfo { get; set; } + + /// + /// Gets or sets the client capabilities derived from the per-request + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client + /// capabilities from previous requests; the authoritative value is the one declared on each request. + /// + public ClientCapabilities? ClientCapabilities { get; set; } + + /// + /// Gets or sets the per-request log level derived from the + /// _meta/io.modelcontextprotocol/logLevel field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Replaces the legacy + /// RPC. When absent, the server MUST NOT emit log notifications + /// for the request. + /// + public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs index 1f648bd5a..a7f26b521 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListPromptsResult : PaginatedResult +public sealed class ListPromptsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of prompts or prompt templates that the server offers. /// [JsonPropertyName("prompts")] public IList Prompts { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs index 6e422a751..988d6f186 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs @@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourceTemplatesResult : PaginatedResult +public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resource templates that the server offers. @@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult /// [JsonPropertyName("resourceTemplates")] public IList ResourceTemplates { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs index 16d01491c..54c1df601 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourcesResult : PaginatedResult +public sealed class ListResourcesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resources that the server offers. /// [JsonPropertyName("resources")] public IList Resources { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs index a2f03b853..55eed5ddb 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListToolsResult : PaginatedResult +public sealed class ListToolsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets the server's response to a tools/list request from the client. /// [JsonPropertyName("tools")] public IList Tools { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs new file mode 100644 index 000000000..8370aaf9a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server cannot fulfill a request because +/// the client did not declare a required capability in its per-request +/// _meta/io.modelcontextprotocol/clientCapabilities field, it MUST return this error so clients +/// know which capabilities to advertise on a retry. +/// +public sealed class MissingRequiredClientCapabilityErrorData +{ + /// + /// Gets or sets the client capabilities the server requires to process the request. + /// + [JsonPropertyName("requiredCapabilities")] + public required ClientCapabilities RequiredCapabilities { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index cab98a5bc..ebb275d63 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -151,4 +151,83 @@ public static class NotificationMethods /// tasks/get would have returned at that moment. /// public const string TaskStatusNotification = "notifications/tasks"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + /// + /// + /// This constant defines the key "io.modelcontextprotocol/related-task" used in the + /// _meta field to associate messages with their originating task across the entire + /// request lifecycle. + /// + /// + /// For example, an elicitation that a task-augmented tool call depends on must share the + /// same related task ID with that tool call's task. + /// + /// + /// For tasks/get, tasks/list, and tasks/cancel operations, this + /// metadata should not be included as the taskId is already present in the message structure. + /// + /// + public const string RelatedTaskMetaKey = "io.modelcontextprotocol/related-task"; + + /// + /// The name of the notification sent first on a + /// response stream to indicate which notification types the server agreed to deliver. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The notification's params mirror the shape + /// of the requested notifications and include only the entries the server actually supports. + /// + public const string SubscriptionsAcknowledgedNotification = "notifications/subscriptions/acknowledged"; + + /// + /// The metadata key used to carry the MCP protocol version in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). For HTTP transports, the value MUST + /// match the MCP-Protocol-Version header. Servers reject mismatched versions with + /// . + /// + public const string ProtocolVersionMetaKey = "io.modelcontextprotocol/protocolVersion"; + + /// + /// The metadata key used to identify the client software in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries an + /// describing the client; replaces the clientInfo previously sent only with initialize. + /// + public const string ClientInfoMetaKey = "io.modelcontextprotocol/clientInfo"; + + /// + /// The metadata key used to declare client capabilities in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a + /// describing what optional features the client supports for this specific request. Servers MUST NOT + /// infer capabilities from previous requests. + /// + public const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + + /// + /// The metadata key used to specify the desired log level for a request's resulting log notifications. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a . + /// Replaces the legacy RPC. When absent, the server + /// MUST NOT send log notifications for the request. + /// + public const string LogLevelMetaKey = "io.modelcontextprotocol/logLevel"; + + /// + /// The metadata key used to associate a notification with the request ID of an active + /// subscription. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Allows clients to demultiplex notifications + /// belonging to different subscriptions on a shared channel (especially STDIO). + /// + public const string SubscriptionIdMetaKey = "io.modelcontextprotocol/subscriptionId"; } diff --git a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs index 084322fde..53e138806 100644 --- a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// /// See the schema for details. /// -public sealed class ReadResourceResult : Result +public sealed class ReadResourceResult : Result, ICacheableResult { /// /// Gets or sets a list of objects that this resource contains. @@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result /// [JsonPropertyName("contents")] public IList Contents { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 47a6fde61..b20216ba3 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestId.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestId.cs @@ -66,7 +66,8 @@ public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, Js { JsonTokenType.String => new(reader.GetString()!), JsonTokenType.Number => new(reader.GetInt64()), - _ => throw new JsonException("requestId must be a string or an integer"), + JsonTokenType.Null => default, + _ => throw new JsonException("requestId must be a string, integer, or null"), }; } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index 6967dd07d..83e5acf80 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -148,4 +148,42 @@ public static class RequestMethods /// Cancellation is cooperative — the server decides whether and when to honor it. /// public const string TasksCancel = "tasks/cancel"; + + /// + /// The name of the request method sent from the client to discover the server's protocol versions, + /// capabilities, and metadata. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) as the canonical way for a client + /// to learn what a server supports without performing the legacy initialize handshake. + /// + /// + /// The server's response includes its supported protocol versions, capabilities, implementation + /// information, and optional usage instructions. + /// + /// + /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Draft-revision clients + /// typically call this once during connection establishment. + /// + /// + public const string ServerDiscover = "server/discover"; + + /// + /// The name of the request method sent from the client to open a long-lived subscription for + /// receiving server-to-client notifications outside of a specific request's response stream. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) and replaces the unsolicited + /// HTTP GET endpoint and the legacy / + /// request methods. + /// + /// + /// The request opens a response stream on which the server first sends a + /// describing the granted + /// notifications, and then streams matching notifications until the subscription is cancelled. + /// + /// + public const string SubscriptionsListen = "subscriptions/listen"; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs new file mode 100644 index 000000000..f4212b2b7 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters sent with a . +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). This notification is the first message on a +/// response stream and informs the client which +/// subset of requested notification types the server has agreed to deliver. +/// +/// +public sealed class SubscriptionsAcknowledgedNotificationParams +{ + /// + /// Gets or sets the notification subscriptions the server has agreed to honor. + /// + /// + /// Only includes notification types the server actually supports. If the client requested an + /// unsupported notification type (e.g., promptsListChanged when the server has no prompts), + /// it is omitted from this set. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs new file mode 100644 index 000000000..a81d45669 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). The client uses this request to open a +/// long-lived channel for receiving notifications outside the context of a specific request. +/// +/// +/// Per-request metadata (protocol version, client info, client capabilities, optional log level) +/// flows through the inherited property under the +/// io.modelcontextprotocol/* keys. +/// +/// +public sealed class SubscriptionsListenRequestParams : RequestParams +{ + /// + /// Gets or sets the notifications the client wants to receive on this subscription stream. + /// + /// + /// Each notification type is opt-in; the server MUST NOT send notification types the client + /// has not explicitly requested here. The server's + /// reports the subset + /// of requested notifications the server actually supports. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} + +/// +/// Describes the set of notification types a client wants to receive (or that a server has agreed +/// to deliver) for a subscription. +/// +public sealed class SubscriptionsListenNotifications +{ + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("toolsListChanged")] + public bool? ToolsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("promptsListChanged")] + public bool? PromptsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("resourcesListChanged")] + public bool? ResourcesListChanged { get; set; } + + /// + /// Gets or sets the list of resource URIs to subscribe to for + /// notifications. + /// + /// + /// Replaces the legacy / + /// RPCs from prior protocol revisions. + /// + [JsonPropertyName("resourceSubscriptions")] + public IList? ResourceSubscriptions { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs new file mode 100644 index 000000000..18386d326 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Provides a JSON converter for that serializes as integer milliseconds. +/// +/// +/// This converter serializes TimeSpan values as the total number of milliseconds (as an integer), +/// and deserializes integer millisecond values back to TimeSpan. System.Text.Json automatically +/// handles nullable TimeSpan properties using this converter. Millisecond values that fall outside +/// the range representable by are clamped to +/// / rather than throwing, so an +/// oversized or malformed hint can never break deserialization of the enclosing result. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class TimeSpanMillisecondsConverter : JsonConverter +{ + /// + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Number) + { + if (reader.TryGetInt64(out long milliseconds)) + { + return FromMillisecondsClamped(milliseconds); + } + + // Non-integer value: fractional, or a magnitude too large to represent. Use the non-throwing + // TryGetDouble so an out-of-range exponent never breaks deserialization. Note that different + // runtimes disagree on out-of-range doubles: in-box .NET returns +/-Infinity, whereas .NET + // Framework's parser reports failure. Handle both so behavior is identical everywhere. + if (reader.TryGetDouble(out double value)) + { + if (double.IsPositiveInfinity(value)) + { + return TimeSpan.MaxValue; + } + + if (double.IsNegativeInfinity(value)) + { + return TimeSpan.MinValue; + } + + return FromTicksClamped(value * TimeSpan.TicksPerMillisecond); + } + + // The runtime could not represent the number as a double at all (e.g. .NET Framework on an + // overflowing exponent). Clamp by the sign of the raw token. + return IsNegativeNumberToken(ref reader) ? TimeSpan.MinValue : TimeSpan.MaxValue; + } + + throw new JsonException($"Unable to convert {reader.TokenType} to TimeSpan."); + } + + private static bool IsNegativeNumberToken(ref Utf8JsonReader reader) + { + ReadOnlySpan token = reader.HasValueSequence ? reader.ValueSequence.First.Span : reader.ValueSpan; + return !token.IsEmpty && token[0] == (byte)'-'; + } + + // Largest whole-millisecond count representable as a TimeSpan (TimeSpan.MaxValue.Ticks / TicksPerMillisecond). + private const long MaxWholeMilliseconds = long.MaxValue / TimeSpan.TicksPerMillisecond; + + // Converts an integer millisecond count to a TimeSpan, clamping out-of-range values to + // TimeSpan.MinValue/MaxValue instead of throwing. A malformed or oversized hint (for example a + // hostile or buggy server returning an enormous ttlMs) must never break deserialization of the + // whole result; per SEP-2549 clients should handle unexpected TTL values gracefully. + private static TimeSpan FromMillisecondsClamped(long milliseconds) + { + if (milliseconds > MaxWholeMilliseconds) + { + return TimeSpan.MaxValue; + } + + if (milliseconds < -MaxWholeMilliseconds) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks(milliseconds * TimeSpan.TicksPerMillisecond); + } + + // Converts a (possibly fractional or out-of-range) tick count to a TimeSpan, clamping instead of + // throwing. The caller passes a value already scaled into tick-space (milliseconds * TicksPerMillisecond) + // because TimeSpan is backed by a long tick count, so comparing against long.MaxValue/MinValue is the + // exact test for whether the final (long) cast would overflow. The comparisons MUST run before that cast: + // double arithmetic saturates to +/-Infinity on overflow rather than throwing, and both infinities fall + // into the clamp branches here (+Infinity >= long.MaxValue, -Infinity <= long.MinValue); if Infinity + // instead reached "(long)ticks" the unchecked conversion would silently yield long.MinValue. NaN is not + // reachable from valid JSON (the only multiplicand is a non-zero constant) but is mapped to zero + // defensively so a non-numeric hint can never break deserialization. + private static TimeSpan FromTicksClamped(double ticks) + { + if (double.IsNaN(ticks)) + { + return TimeSpan.Zero; + } + + if (ticks >= long.MaxValue) + { + return TimeSpan.MaxValue; + } + + if (ticks <= long.MinValue) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks((long)ticks); + } + + /// + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue((long)value.TotalMilliseconds); + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs new file mode 100644 index 000000000..ac394db90 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server receives a request whose +/// declared protocol version it does not implement, it MUST return this error so clients can +/// fall back to a mutually supported version. +/// +public sealed class UnsupportedProtocolVersionErrorData +{ + /// + /// Gets or sets the protocol version strings that the server supports. + /// + [JsonPropertyName("supported")] + public required IList Supported { get; set; } + + /// + /// Gets or sets the protocol version requested by the client. + /// + [JsonPropertyName("requested")] + public required string Requested { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 85b1cc26a..71873927b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -87,11 +87,13 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact // Configure all request handlers based on the supplied options. ServerCapabilities = new(); ConfigureInitialize(options); + ConfigureDiscover(options); ConfigureTools(options); ConfigurePrompts(options); ConfigureResources(options); ConfigureLogging(options); ConfigureCompletion(options); + ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); ConfigureTasks(options); ConfigureMrtr(); @@ -136,17 +138,151 @@ void Register(McpServerPrimitiveCollection? collection, // And initialize the session. var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); + + // Prepend a built-in filter that picks up per-request _meta values populated by + // McpSessionHandler.PopulateContextFromMeta and projects them onto the server's + // per-session state. Under the draft protocol revision (SEP-2575) the client no longer + // performs an initialize handshake, so this is the only place client info / capabilities + // / negotiated protocol version are recorded server-side. This filter is a no-op for + // legacy clients that already populated these via initialize. + var draftStateSyncFilter = CreateDraftStateSyncFilter(); + var combinedIncomingFilter = ComposeFilters(draftStateSyncFilter, incomingMessageFilter); + _sessionHandler = new McpSessionHandler( isServer: true, _sessionTransport, _endpointName!, _requestHandlers, _notificationHandlers, - incomingMessageFilter, + combinedIncomingFilter, outgoingMessageFilter, _logger); } + /// Composes two s so runs first. + private static JsonRpcMessageFilter ComposeFilters(JsonRpcMessageFilter outer, JsonRpcMessageFilter inner) => + next => outer(inner(next)); + + /// + /// Builds an incoming message filter that, for every JSON-RPC request, synchronizes server-side state + /// (, , ) + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version. + /// + /// + /// Under the draft protocol revision (SEP-2575) there is no initialize handshake, so these values + /// MUST be populated per-request. For legacy clients the per-request values are absent and this filter + /// is a no-op (the values were captured during the initialize handler). + /// + private JsonRpcMessageFilter CreateDraftStateSyncFilter() + { + return next => async (message, cancellationToken) => + { + if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + { + bool endpointNameNeedsRefresh = false; + + if (context.ProtocolVersion is { } protocolVersion) + { + // Per SEP-2575, the server MUST reject any request whose per-request + // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions + // with an UnsupportedProtocolVersionError (-32004) carrying the supported list. + if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: McpSessionHandler.SupportedProtocolVersions); + } + + if (!string.Equals(_negotiatedProtocolVersion, protocolVersion, StringComparison.Ordinal)) + { + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + } + } + + if (context.ClientCapabilities is { } clientCapabilities && IsStatefulSession()) + { + // Defensive merge instead of overwrite. SEP-2575 says the per-request envelope is + // the client's full capabilities, but PR #1579's GetMetaWithTaskCapability emits a + // partial envelope (only extensions.io.modelcontextprotocol/tasks) on every + // tools/call regardless of the negotiated protocol version. If we overwrote here, + // a legacy client that called initialize with { Elicitation = new() } would lose + // its elicitation capability the moment it issued a tools/call. Merging non-null + // fields preserves whatever the initialize handshake (or a prior, more complete + // envelope) established. + // + // The IsStatefulSession() gate prevents leaking per-request capability state into + // _clientCapabilities under StreamableHttpServerTransport { Stateless = true } + // (where _clientCapabilities is otherwise null and StatelessServerTests rely on + // that invariant to surface the "X is not supported in stateless mode" errors). + _clientCapabilities = MergeClientCapabilities(_clientCapabilities, clientCapabilities); + } + + if (context.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) + { + _clientInfo = clientInfo; + endpointNameNeedsRefresh = true; + } + + if (endpointNameNeedsRefresh) + { + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; + } + } + + await next(message, cancellationToken).ConfigureAwait(false); + }; + } + + /// + /// Merges per-request envelope values onto the existing + /// session-scoped capabilities, preserving fields that the envelope leaves unset. + /// + /// + /// SEP-2575 treats the per-request envelope as the client's full capabilities for the request, but + /// at least one extension (SEP-2663 Tasks) emits a partial envelope advertising only + /// extensions.io.modelcontextprotocol/tasks = {} on every tools/call. Overwriting the + /// captured initialize-time capabilities with that partial envelope would silently drop other + /// declared capabilities (e.g., elicitation), so we merge per-field instead. + /// + private static ClientCapabilities MergeClientCapabilities(ClientCapabilities? existing, ClientCapabilities envelope) + { + if (existing is null) + { + return envelope; + } + + IDictionary? mergedExtensions = existing.ExtensionsCore; + if (envelope.ExtensionsCore is { Count: > 0 } envelopeExtensions) + { + if (mergedExtensions is null) + { + mergedExtensions = new Dictionary(envelopeExtensions); + } + else + { + // Per-request extensions are additive; don't strip ones declared at initialize. + foreach (var kvp in envelopeExtensions) + { + mergedExtensions[kvp.Key] = kvp.Value; + } + } + } + + return new ClientCapabilities + { + Roots = envelope.Roots ?? existing.Roots, + Sampling = envelope.Sampling ?? existing.Sampling, + Elicitation = envelope.Elicitation ?? existing.Elicitation, + Experimental = envelope.Experimental ?? existing.Experimental, + ExtensionsCore = mergedExtensions, + }; + } + /// public override string? SessionId => _sessionTransport.SessionId; @@ -289,6 +425,104 @@ private void ConfigureInitialize(McpServerOptions options) McpJsonUtilities.JsonContext.Default.InitializeResult); } + /// + /// Registers the server/discover request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's + /// supported protocol versions (), server + /// capabilities, server info, and optional instructions. + /// + private void ConfigureDiscover(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.ServerDiscover, + (request, _, _) => + { + return new ValueTask(new DiscoverResult + { + SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], + Capabilities = ServerCapabilities ?? new(), + ServerInfo = options.ServerInfo ?? DefaultImplementation, + Instructions = options.ServerInstructions, + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to + // the safest values (immediately stale, not shareable) so existing servers keep + // their "do not cache" behavior while satisfying the wire requirement. + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }); + }, + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult); + } + + /// + /// Registers the subscriptions/listen request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// + /// The handler opens a long-lived response stream (over the per-request + /// for HTTP, or the shared STDIO channel) that first sends + /// reporting which subscriptions the + /// server agreed to honor, and then streams matching notifications until the request is cancelled. + /// + /// + /// Subscription-bound notifications carry the listen request's id in their + /// _meta/io.modelcontextprotocol/subscriptionId field per SEP-2575 so clients can demultiplex. + /// + /// + private void ConfigureSubscriptions(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.SubscriptionsListen, + async (request, jsonRpcRequest, cancellationToken) => + { + // Filter the requested notifications against what the server actually supports. + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); + var granted = new SubscriptionsListenNotifications + { + ToolsListChanged = requested.ToolsListChanged == true && ServerCapabilities?.Tools?.ListChanged == true ? true : null, + PromptsListChanged = requested.PromptsListChanged == true && ServerCapabilities?.Prompts?.ListChanged == true ? true : null, + ResourcesListChanged = requested.ResourcesListChanged == true && ServerCapabilities?.Resources?.ListChanged == true ? true : null, + ResourceSubscriptions = requested.ResourceSubscriptions is { Count: > 0 } subs && ServerCapabilities?.Resources?.Subscribe == true + ? new List(subs) + : null, + }; + + // Track this subscription so notifications can tag themselves with the right subscriptionId + // and so we can stream resource-updated notifications for the requested URIs. + var subscription = new ActiveSubscription(jsonRpcRequest.Id, granted, jsonRpcRequest.Context?.LogLevel); + _activeSubscriptions[jsonRpcRequest.Id] = subscription; + + try + { + // Send the acknowledgement notification first, as required by SEP-2575. + await this.SendNotificationAsync( + NotificationMethods.SubscriptionsAcknowledgedNotification, + new SubscriptionsAcknowledgedNotificationParams { Notifications = granted }, + McpJsonUtilities.JsonContext.Default.SubscriptionsAcknowledgedNotificationParams, + cancellationToken).ConfigureAwait(false); + + // Keep the subscription open until the request is cancelled (client disconnect on HTTP, + // or notifications/cancelled on STDIO). + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), tcs); + await tcs.Task.ConfigureAwait(false); + } + finally + { + _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); + } + + return new EmptyResult(); + }, + McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, + McpJsonUtilities.JsonContext.Default.EmptyResult); + } + + /// Tracks an active subscriptions/listen subscription for notification fan-out. + private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifications Granted, LoggingLevel? LogLevel); + + private readonly ConcurrentDictionary _activeSubscriptions = new(); + private void ConfigureCompletion(McpServerOptions options) { var completeHandler = options.Handlers.CompleteHandler; @@ -1386,7 +1620,7 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => }; /// - /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (2026-07-28). /// internal bool ClientSupportsMrtr() => _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; @@ -1444,7 +1678,7 @@ internal bool IsStatefulSession() => // In stateless mode without MRTR, the server can't resolve input requests via // JSON-RPC (no persistent session for server-to-client requests), and the client // won't recognize the InputRequiredResult. This is the one unsupported configuration. - // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. + // TODO(stateless-draft): When 2026-07-28 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. if (!IsStatefulSession()) { throw new McpException( @@ -1671,7 +1905,7 @@ private void WrapHandlerWithMrtr(string method) } // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits - // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, + // InputRequiredResult on the wire, which only 2026-07-28 clients understand, // and requires the same server instance to handle the retry (stateful session). // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via diff --git a/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs new file mode 100644 index 000000000..fc37e05cd --- /dev/null +++ b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs @@ -0,0 +1,74 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request's declared protocol version is not supported by the server. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when they cannot process +/// a request because the per-request _meta/io.modelcontextprotocol/protocolVersion (or the equivalent +/// transport-level header) names a version the server does not implement. The exception is converted to a +/// JSON-RPC error response with code (-32004) and +/// a payload. +/// +public sealed class UnsupportedProtocolVersionException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The protocol version the client requested. + /// The protocol versions the server supports. + /// A human-readable description of the error. If , a default message is used. + public UnsupportedProtocolVersionException(string requested, IEnumerable supported, string? message = null) + : base(message ?? $"Unsupported protocol version '{requested}'.", McpErrorCode.UnsupportedProtocolVersion) + { + Throw.IfNull(requested); + Throw.IfNull(supported); + + Requested = requested; + Supported = new List(supported); + } + + /// Gets the protocol version the client requested. + public string Requested { get; } + + /// Gets the protocol versions the server supports. + public IReadOnlyList Supported { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new UnsupportedProtocolVersionErrorData + { + Requested = Requested, + Supported = (IList)Supported, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out UnsupportedProtocolVersionException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData); + if (payload is null) + { + return false; + } + + exception = new UnsupportedProtocolVersionException(payload.Requested, payload.Supported, formattedMessage); + return true; + } +} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index ef1686abb..b549bdd76 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -1,5 +1,8 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Utils; @@ -78,16 +81,25 @@ public static void EnsureNpmDependenciesInstalled() /// /// The name of the binary in node_modules/.bin (e.g. "conformance"). /// The arguments to pass to the binary. + /// + /// When (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION + /// environment variable is set, a "--spec-version <value>" argument is appended. + /// Pass for scenarios that pin their own spec version (e.g. the + /// draft-only caching scenario) to avoid a conflicting duplicate flag. + /// /// A configured ProcessStartInfo for running the binary. - public static ProcessStartInfo ConformanceTestStartInfo(string arguments) + public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true) { EnsureNpmDependenciesInstalled(); // If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner. - var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); - if (!string.IsNullOrEmpty(protocolVersion)) + if (appendProtocolVersionFromEnv) { - arguments += $" --spec-version {protocolVersion}"; + var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); + if (!string.IsNullOrEmpty(protocolVersion)) + { + arguments += $" --spec-version {protocolVersion}"; + } } var repoRoot = FindRepoRoot(); @@ -168,36 +180,63 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available by reading - /// the conformance package version from the repo's package.json. + /// Checks whether the SEP-2243 conformance scenarios are available, by reading the + /// installed conformance package version from node_modules. /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios - /// require a conformance package version that includes SEP-2243 support. + /// http-header-validation, and http-custom-header-server-validation scenarios were + /// introduced in conformance package 0.2.0. Reading the installed version (rather than + /// the pinned version in package.json) means this also returns + /// when a newer private build has been installed locally via + /// npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// public static bool HasSep2243Scenarios() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); + + /// + /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance + /// PR #275) is available, by reading the installed conformance package version + /// from node_modules. The caching scenario was introduced in conformance package 0.2.0. + /// Reading the installed version (rather than the pinned version in package.json) means + /// this also returns when a newer private build has been installed + /// locally via npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . + /// + public static bool HasCachingScenario() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); + + /// + /// Returns when the installed conformance package's bundled + /// dist emits the same draft protocol version string as this SDK + /// (). Used to suppress draft-only + /// conformance scenarios when the published conformance binary is still pinned to a + /// stale wire string (for example, conformance 0.2.0-alpha.2 ships + /// "DRAFT-2026-v1" while this SDK speaks "2026-07-28"). + /// + /// + /// This check is a pragmatic alternative to inspecting the conformance package's + /// internal constants: the bundled dist/index.js is minified so we can't grep + /// the constant name, but the literal version string survives bundling and is unique + /// enough to be a reliable signal. + /// + public static bool HasMatchingDraftWireVersion() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); - if (!File.Exists(packageJsonPath)) + var distPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + if (!File.Exists(distPath)) { return false; } - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) - { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) - { - // SEP-2243 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); - } - } - - return false; + var bundled = File.ReadAllText(distPath); + return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); } catch { @@ -206,43 +245,193 @@ public static bool HasSep2243Scenarios() } /// - /// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult) - /// conformance scenarios are available by reading the conformance package version - /// from the repo's package.json. MRTR scenarios require a conformance package version - /// that includes SEP-2322 support (see - /// https://github.com/modelcontextprotocol/conformance/pull/188). + /// Returns when the conformance package installed in node_modules + /// has a version greater than or equal to . /// - public static bool HasMrtrScenarios() + private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) + { + var version = GetInstalledConformanceVersion(); + return version is not null && version >= minimumVersion; + } + + /// + /// Reads the version of the conformance package actually installed in node_modules, + /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be + /// parsed as a . Returns if it cannot be + /// determined. + /// + private static Version? GetInstalledConformanceVersion() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); + var packageJsonPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + + // This is a skip gate for version-conditional conformance scenarios, so it must stay + // side-effect-free. If the conformance package isn't installed, report no version (the + // scenario is simply gated off); the actual scenario run path restores npm dependencies + // separately via ConformanceTestStartInfo. if (!File.Exists(packageJsonPath)) { - return false; + return null; } - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("version", out var versionElement) && + versionElement.GetString() is { } versionStr) { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) + // Strip any prerelease/build suffix so System.Version can parse it. + var core = versionStr.Split('-', '+')[0]; + if (Version.TryParse(core, out var version)) { - // SEP-2322 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); + return version; } } - return false; + return null; } catch + { + return null; + } + } + + /// + /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process + /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) + /// shared by the server-side conformance tests. + /// + /// Arguments to pass to the conformance runner. + /// Optional callback invoked for each captured stdout/stderr line. + /// + /// Forwarded to . + /// + /// Token used to cancel the run. + public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync( + string arguments, + Action? onLine = null, + bool appendProtocolVersionFromEnv = true, + CancellationToken cancellationToken = default) + { + var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + using var process = new Process { StartInfo = startInfo }; + + // Protect callbacks with try/catch so a callback that throws on a background thread + // (e.g. ITestOutputHelper after the test completes) does not crash the test host. + DataReceivedEventHandler outputHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + outputBuilder.AppendLine(e.Data); + } + }; + + DataReceivedEventHandler errorHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + errorBuilder.AppendLine(e.Data); + } + }; + + process.OutputDataReceived += outputHandler; + process.ErrorDataReceived += errorHandler; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + try + { +#if NET + await process.WaitForExitAsync(cts.Token); +#else + // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill + // extension and surface a timeout the same way the modern path does. + await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); + if (!process.HasExited) + { + throw new OperationCanceledException(); + } +#endif + } + catch (OperationCanceledException) + { +#if NET + process.Kill(entireProcessTree: true); +#else + process.Kill(); +#endif + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + return ( + false, + outputBuilder.ToString(), + errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."); + } + + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + + var stdoutText = outputBuilder.ToString(); + var stderrText = errorBuilder.ToString(); + + // The Node.js conformance runner can crash during cleanup on Windows with a libuv + // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit + // code even though every conformance check passed. When that happens, fall back to + // parsing the "Test Results:" summary in stdout to decide success. + bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); + + return (success, stdoutText, stderrText); + } + + /// + /// Parses the conformance runner output for a "Test Results:" line such as + /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed + /// and none failed. + /// + private static bool ConformanceOutputIndicatesSuccess(string output) + { + // Match lines like "Passed: 3/3, 0 failed, 0 warnings" + var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); + if (!match.Success) { return false; } + + int passed = int.Parse(match.Groups[1].Value); + int total = int.Parse(match.Groups[2].Value); + int failed = int.Parse(match.Groups[3].Value); + + return passed == total && failed == 0 && total > 0; } + /// + /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) + /// conformance scenarios are available, by reading the installed conformance + /// package version from node_modules. The incomplete-result-* scenarios were + /// introduced in conformance package 0.2.0 (see + /// https://github.com/modelcontextprotocol/conformance/pull/188). + /// Reading the installed version (rather than the pinned version in package.json) means + /// this also returns when a newer private build has been installed + /// locally via npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . + /// + public static bool HasMrtrScenarios() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); + private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 852fb122e..9ad5f8afe 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -46,7 +46,7 @@ private async Task StartAsync() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs new file mode 100644 index 000000000..27cba0d40 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft +/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound +/// when the caching test is skipped) and torn down via . Uses a +/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so +/// the two can run in parallel without TCP conflicts. +/// +internal sealed class StatelessConformanceServer : IAsyncDisposable +{ + // Use different ports for each target framework to allow parallel execution across the + // multi-targeted test processes, offset from a caller-supplied base port so independent + // stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0, + // net9.0 -> +1, net8.0 -> +2. + private static int GetPortForTargetFramework(int basePort) + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + var offset = targetFramework switch + { + "net10.0" => 0, + "net9.0" => 1, + "net8.0" => 2, + _ => 0 // Default fallback + }; + + return basePort + offset; + } + + private readonly Task _serverTask; + private readonly CancellationTokenSource _serverCts; + + public string ServerUrl { get; } + + private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts) + { + ServerUrl = serverUrl; + _serverTask = serverTask; + _serverCts = serverCts; + } + + public static async Task StartAsync(CancellationToken cancellationToken, int basePort = 3011) + { + var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}"; + var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle + // (see ConformanceServer.Program), without mutating process-wide environment state. + var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( + ["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token)); + + // Wait for the server to be ready (retry for up to 30 seconds). + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + + while (stopwatch.Elapsed < timeout) + { + try + { + await httpClient.GetAsync($"{serverUrl}/health", cancellationToken); + return new StatelessConformanceServer(serverUrl, serverTask, serverCts); + } + catch (HttpRequestException) + { + // Connection refused means the server is not ready yet. + } + catch (TaskCanceledException) + { + // Timeout means the server might be processing; give it more time. + } + + await Task.Delay(500, cancellationToken); + } + + serverCts.Cancel(); + serverCts.Dispose(); + throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + _serverCts.Cancel(); + try + { + await _serverTask.WaitAsync(TestConstants.DefaultTimeout); + } + catch + { + // Ignore exceptions during shutdown. + } + _serverCts.Dispose(); + } +} + +/// +/// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, +/// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK +/// correctly emits the ttlMs and cacheScope caching hints on cacheable results +/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). +/// +/// +/// The scenario is draft-only (introduced in spec wire version 2026-07-28) and uses the +/// stateless lifecycle. It is gated on the installed conformance package version (>= 0.2.0) +/// AND on the installed package emitting the draft wire string this SDK speaks (so it stays +/// skipped under conformance 0.2.0-alpha.2 which still ships the placeholder +/// DRAFT-2026-v1). It activates automatically once a conformance package emitting +/// 2026-07-28 is installed (e.g. via +/// npm install --no-save <path-to-conformance>). The stateless server is +/// started only after the gates pass, so a skipped run binds no port. +/// +public class CachingConformanceTests(ITestOutputHelper output) +{ + [Fact] + public async Task RunCachingConformanceTest() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + !NodeHelpers.HasCachingScenario(), + "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); + + await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); + + // The caching scenario only exists in the draft spec, so pin the spec version + // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a + // conflicting duplicate --spec-version flag). + var result = await NodeHelpers.RunServerConformanceAsync( + $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success, + $"SEP-2549 caching conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 7b2be118b..f389ffbba 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -63,7 +63,7 @@ public async Task RunConformanceTest(string scenario) } // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-custom-headers")] [InlineData("http-invalid-tool-headers")] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs new file mode 100644 index 000000000..55401c15a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs @@ -0,0 +1,305 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path over Streamable HTTP. These +/// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's +/// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body +/// on a draft probe; vanilla Go does the same on POST /) so the client's HTTP-fallback +/// logic can be exercised in isolation without the cross-SDK harness. +/// +/// +/// +/// Two latent bugs were discovered during cross-SDK testing and fixed by the SEP-2575 / SEP-2567 +/// branch: +/// +/// +/// +/// only surfaced the three modern draft +/// error codes (-32004, -32003, -32001) as ; +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// that doesn't understand the draft _meta envelope) threw +/// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger +/// on ANY non-modern JSON-RPC error in a 400 body. +/// +/// +/// treated any non-2xx HTTP response as a +/// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked +/// application-level errors (including the three modern codes) because the SSE GET would +/// either fail with "session id required" or succeed against a different endpoint and lose +/// the actual signal. +/// +/// +/// +public class DraftHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private async Task StartServerAsync(RequestDelegate handler) + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + + _app = Builder.Build(); + _app.MapPost("/mcp", handler); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatusCode statusCode, int code, string message) + { + var rpcError = new JsonRpcError + { + Id = default, + Error = new JsonRpcErrorDetail { Code = code, Message = message }, + }; + + context.Response.StatusCode = (int)statusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), context.RequestAborted); + } + + /// + /// Mimics Python's simple-streamablehttp-stateless on a draft probe: returns + /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the + /// initial server/discover, then performs a normal legacy initialize handshake + /// when the client falls back. + /// + [Fact] + public async Task DraftClient_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + // Draft probe: simulate a legacy server that rejects the unknown protocol version with + // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). + if (request.Method == RequestMethods.ServerDiscover) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: draft"); + return; + } + + // Legacy initialize: respond with the highest version the legacy server speaks. + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-06-18", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (request.Method == RequestMethods.ToolsList) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + // Default AutoDetect transport — exercises BOTH fixes (AutoDetect adopting StreamableHttp + // on JSON-RPC-error 400, and SendMessageAsync surfacing -32600 as McpProtocolException). + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + + // Sanity: subsequent traffic still works post-fallback. + var tools = await client.ListToolsAsync(cancellationToken: ct); + Assert.Empty(tools); + } + + /// + /// Mimics vanilla Go: returns 400 + JSON-RPC -32004 with + /// data.supported[] on a draft probe so the client retries legacy + /// initialize with one of the advertised versions. + /// + [Fact] + public async Task DraftClient_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + if (request.Method == RequestMethods.ServerDiscover) + { + // -32004 with the spec-shaped data: client should retry with one of supported[]. + // Use the typed payload type so the source-generated serializer can handle it. + var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData + { + Supported = new List { "2025-11-25" }, + Requested = "draft", + }, GetJsonTypeInfo()); + + var rpcError = new JsonRpcError + { + Id = request.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = data, + }, + }; + + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), ct); + return; + } + + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-11-25", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + /// + /// A 400 with a JSON-RPC -32001 HeaderMismatch envelope must be surfaced to the + /// caller (no legacy fallback) — falling back wouldn't fix a malformed envelope. + /// + [Fact] + public async Task DraftClient_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() + { + var ct = TestContext.Current.CancellationToken; + bool initializeReceived = false; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeReceived = true; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, + code: (int)McpErrorCode.HeaderMismatch, + message: "Header mismatch: MCP-Protocol-Version did not match body _meta"); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); + Assert.False(initializeReceived); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs new file mode 100644 index 000000000..4d83a410e --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// HTTP-level tests for the draft protocol revision (SEP-2575 + SEP-2567): verify that the server +/// suppresses the Mcp-Session-Id header for draft requests and returns structured +/// errors instead of plain 400s. +/// +public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; + }).WithHttpTransport(options => + { + // Map the GET/DELETE endpoints so we can exercise the draft-mode rejection paths + // (these endpoints are not registered in stateless mode, which is the new default). + options.Stateless = false; + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + // server/discover should succeed without creating a session. + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id"); + } + + [Fact] + public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var rpcMessage = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + var rpcError = Assert.IsType(rpcMessage); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code); + + // Validate the structured data payload (SEP-2575 §"Unsupported Protocol Versions"). + var dataElement = (JsonElement)rpcError.Error.Data!; + var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); + Assert.NotNull(errorData); + Assert.Equal("2099-12-31", errorData.Requested); + Assert.NotEmpty(errorData.Supported); + } + + [Fact] + public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath() + { + // For back-compat with clients that opted into the experimental version on top of the legacy + // stateful session model (MRTR-as-extension-on-initialize), draft-version requests that DO + // include an Mcp-Session-Id are still accepted via the legacy session lookup path. + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + // Legacy path returns 404 for unknown sessions. + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DraftGet_WithoutSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftDelete_WithoutSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index b950553f5..e0a7c07a0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -122,7 +122,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "union_test"); request.Headers.Add("Mcp-Param-Priority", "42"); @@ -141,7 +141,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "union_test"); request.Headers.Add("Mcp-Param-Priority", "9007199254740993"); @@ -161,7 +161,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -185,7 +185,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test "); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -208,7 +208,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call "); request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -232,7 +232,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -255,7 +255,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -281,7 +281,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", encodedValue!); @@ -306,7 +306,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -334,7 +334,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -363,7 +363,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -391,7 +391,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -414,7 +414,7 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -476,7 +476,7 @@ public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); // Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕"); @@ -555,7 +555,7 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() #region Version gating tests [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] @@ -576,15 +576,15 @@ private async Task InitializeWithDraftVersionAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Draft protocol revision (SEP-2567) is sessionless: the server does not return a + // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 + // to route through the sessionless path. } private async Task InitializeWithNonDraftVersionAsync() @@ -594,9 +594,8 @@ private async Task InitializeWithNonDraftVersionAsync() using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return + // a mcp-session-id header. Subsequent requests are independent, just like the draft path. } private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); @@ -616,7 +615,7 @@ private string CallTool(string toolName, string arguments = "{}") """; private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; #endregion diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs index ef385ed70..3190d7a38 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -211,7 +211,9 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() { Builder.Services .AddMcpServer() - .WithHttpTransport(); +#pragma warning disable MCP9005 // Stateful Streamable HTTP option is obsolete but required to exercise the stateful timer path. + .WithHttpTransport(options => options.Stateless = false); +#pragma warning restore MCP9005 using var app = Builder.Build(); @@ -220,7 +222,7 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() await idleTrackingService.StartAsync(TestContext.Current.CancellationToken); - // In the default (stateful) mode the timer loop must start, so ExecuteTask should be set. + // With Stateless = false the timer loop must start, so ExecuteTask should be set. Assert.NotNull(idleTrackingService.ExecuteTask); await idleTrackingService.StopAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index b796d78c2..05f9bdff3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -21,7 +21,7 @@ protected override void ConfigureStateless(HttpServerTransportOptions options) [InlineData("/mcp/secondary")] public async Task Allows_Customizing_Route(string pattern) { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(pattern); @@ -53,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat Name = "TestCustomRouteServer", Version = "1.0.0", }; - }).WithHttpTransport(options => options.EnableLegacySse = true); + }).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(routePattern); @@ -83,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode() return "Complete"; }, options: new() { Name = "polling_tool" }); - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index ddae6c66b..4ebd82664 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -17,7 +17,7 @@ private ServerMessageTracker ConfigureServer(params Delegate[] tools) { options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; // Do not pin a protocol version - let it be negotiated based on what the client requests. - // DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get + // 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get // the latest non-draft. messageTracker.AddFilters(options.Filters.Message); }) @@ -30,7 +30,7 @@ private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); private Task ConnectDefaultAsync() => @@ -79,7 +79,7 @@ private static void ConfigureMrtrHandlers(McpClientOptions options) // ===================================================================== // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. - // Each test creates its own server with DRAFT-2026-v1 enabled. + // Each test creates its own server with 2026-07-28 enabled. // ===================================================================== [McpServerTool(Name = "mrtr-mixed")] @@ -156,8 +156,15 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. @@ -180,6 +187,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // and no persistent server instance for the backcompat retry loop). The server returns // a JSON-RPC error. await using var client = await ConnectAsync(configureClient: configureClient); + var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken).AsTask()); @@ -202,7 +210,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // Stateful path - both client modes complete all 3 rounds. await using var statefulClient = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", statefulClient.NegotiatedProtocolVersion); var result = await statefulClient.CallToolAsync("mrtr-mixed", @@ -267,6 +275,10 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + // Under the draft protocol revision (SEP-2567), the server is implicitly stateless for draft + // clients, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client + // case for the same reason as Mrtr_MixedExceptionAndAwaitStyle. + Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless."); ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); @@ -274,7 +286,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); @@ -283,7 +295,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) { // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second // call throws InvalidOperationException, which the tool catches and returns as text. - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); @@ -351,7 +363,7 @@ public async Task Mrtr_Roots_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots", cancellationToken: TestContext.Current.CancellationToken); @@ -413,7 +425,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); @@ -437,7 +449,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) if (experimentalClient) { - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrUsed(); } else @@ -459,10 +471,10 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-check", cancellationToken: TestContext.Current.CancellationToken); @@ -526,7 +538,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() await using var client = await ConnectAsync(configureClient: options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; options.Handlers.ElicitationHandler = async (request, ct) => { elicitCalled.TrySetResult(); @@ -553,7 +565,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() }; }; }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-concurrent-three", cancellationToken: TestContext.Current.CancellationToken); @@ -582,7 +594,7 @@ public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-loadshed", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index acdcfa456..320acce6f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -7,6 +7,8 @@ false true ModelContextProtocol.AspNetCore.Tests + + $(NoWarn);MCP9005 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 6be82aec0..65cd8909b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -31,7 +31,7 @@ private async Task StartAsync() Name = nameof(MrtrProtocolTests), Version = "1", }; - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }).WithTools([ McpServerTool.Create( async (string message, McpServer server, CancellationToken ct) => @@ -56,7 +56,7 @@ private async Task StartAsync() Name = "throwing-tool", Description = "A tool that throws immediately" }), - ]).WithHttpTransport(); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); @@ -229,9 +229,9 @@ public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() [Fact] public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { - // Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current + // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current // protocol with a legacy client. The backcompat resolver path only runs when the - // negotiated version is not DRAFT-2026-v1. + // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation @@ -262,7 +262,7 @@ static string (RequestContext context) => Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); @@ -395,7 +395,7 @@ private Task PostJsonRpcAsync(string json) { var content = JsonContent(json); - // DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. + // 2026-07-28 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. // Parse the body to derive them and attach to this request only. var bodyNode = JsonNode.Parse(json); if (bodyNode is JsonObject obj) @@ -444,7 +444,7 @@ private string CallTool(string toolName, string arguments = "{}") => private async Task InitializeWithMrtrAsync() { var initJson = """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} """; using var response = await PostJsonRpcAsync(initJson); @@ -453,7 +453,7 @@ private async Task InitializeWithMrtrAsync() // Verify the server negotiated to the experimental version var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); - Assert.Equal("DRAFT-2026-v1", protocolVersion); + Assert.Equal("2026-07-28", protocolVersion); var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); @@ -461,7 +461,7 @@ private async Task InitializeWithMrtrAsync() // Set the MCP-Protocol-Version header for subsequent requests HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-07-28"); // Reset request ID counter since initialize used ID 1 _lastRequestId = 1; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 3c1919b0b..f9a4b64c0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -62,7 +62,7 @@ protected OAuthTestBase(ITestOutputHelper outputHelper, bool configureMcpMetadat }); Builder.Services.AddAuthorization(); - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = false); } public async ValueTask DisposeAsync() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs new file mode 100644 index 000000000..909325c66 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -0,0 +1,196 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Wire-format conformance tests for the Streamable HTTP server driven directly via , +/// without going through . These hand-craft HTTP +/// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 + +/// SEP-2567 (sessionless, no-initialize) draft revision. +/// +public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string ProtocolVersionHeader = "MCP-Protocol-Version"; + + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + }) + .WithHttpTransport() + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + + /// + /// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the + /// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation; + /// raw HttpClient tests should accept either. + /// + private static async Task ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var contentType = response.Content.Headers.ContentType?.MediaType; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (contentType == "text/event-stream") + { + // Pull the first non-empty data: line out of the SSE payload. + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + var data = line.Substring("data:".Length).Trim(); + if (data.Length > 0) + { + return JsonNode.Parse(data)!; + } + } + } + throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body); + } + + return JsonNode.Parse(body)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task DraftToolsCall_WithFullMeta_Succeeds_200() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); + + // Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id. + Assert.False(response.Headers.Contains("mcp-session-id")); + } + + [Fact] + public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the + // safest defaults (immediately stale, not shareable) when the application hasn't customized. + Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind()); + Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); + Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); + } + + [Fact] + public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, "9999-99-99"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 and a data payload + // listing the supported versions. The dual-era client uses this to switch versions without fallback. + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + + var data = json["error"]!["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() + { + await StartAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.Accept.Add(new("text/event-stream")); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // stream is replaced by subscriptions/listen POST requests. Existing routing in + // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } +} + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 9738ffda3..b64f12d95 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,6 +490,8 @@ protected async Task CreateServerAsync( var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { + // Resumability is a stateful concern; pin Stateless = false now that the new default is true. + options.Stateless = false; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index ea4187a95..22c86521c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -37,8 +35,10 @@ private static int GetPortForTargetFramework() public async ValueTask InitializeAsync() { _serverCts = new CancellationTokenSource(); + // Explicitly pass "--stateless false" so this stateful fixture is immune to a globally + // set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins). _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + ["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token)); // Wait for server to be ready (retry for up to 30 seconds) var timeout = TimeSpan.FromSeconds(30); @@ -139,9 +139,19 @@ public async Task RunPendingConformanceTest_ServerSsePolling() public async Task RunConformanceTest_HttpHeaderValidation() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + // SEP-2243 is a draft (2026-07-28) scenario that uses the stateless lifecycle, so it + // requires a stateless server (a stateful server rejects the un-initialized list/call + // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with + // the stateful class fixture (300x) or the caching stateless server (301x). + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3021); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -151,34 +161,57 @@ public async Task RunConformanceTest_HttpHeaderValidation() public async Task RunConformanceTest_HttpCustomHeaderServerValidation() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3024); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. + // SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) conformance scenarios. // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via - // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. - // Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1 + // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts + // (the class names predate the conformance-suite rename from "incomplete-result-*" to + // "input-required-result-*"; the wire-level tool names now match the new convention). + // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28 // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the - // upstream conformance package ships with SEP-2322 scenarios - // (https://github.com/modelcontextprotocol/conformance/pull/188). + // installed conformance package ships SEP-2322 scenarios and emits this SDK's + // draft wire string (see ). + // + // Two scenarios (input-required-result-tampered-state and input-required-result-capability-check) + // require advanced server-side logic not yet built into the ConformanceServer: + // - tampered-state: HMAC integrity protection on requestState. Server-implementer concern + // outside the SDK wire surface; would need a sample tool implementing the pattern. + // - capability-check: per-request reading of clientCapabilities to gate which inputRequests + // are returned. SDK exposes capabilities via JsonRpcMessageContext but no current tool + // conditionally emits inputRequests based on them. + // These rows are skipped until matching tool implementations are added. [Theory] - [InlineData("incomplete-result-basic-elicitation")] - [InlineData("incomplete-result-basic-sampling")] - [InlineData("incomplete-result-basic-list-roots")] - [InlineData("incomplete-result-request-state")] - [InlineData("incomplete-result-multiple-input-requests")] - [InlineData("incomplete-result-multi-round")] - [InlineData("incomplete-result-missing-input-response")] - [InlineData("incomplete-result-non-tool-request")] + [InlineData("input-required-result-basic-elicitation")] + [InlineData("input-required-result-basic-sampling")] + [InlineData("input-required-result-basic-list-roots")] + [InlineData("input-required-result-request-state")] + [InlineData("input-required-result-multiple-input-requests")] + [InlineData("input-required-result-multi-round")] + [InlineData("input-required-result-missing-input-response")] + [InlineData("input-required-result-non-tool-request")] + [InlineData("input-required-result-result-type")] + [InlineData("input-required-result-unsupported-methods")] + [InlineData("input-required-result-tampered-state", Skip = "Requires HMAC-protected requestState pattern in ConformanceServer tools (not yet implemented).")] + [InlineData("input-required-result-capability-check", Skip = "Requires per-request capability-aware inputRequest gating in ConformanceServer tools (not yet implemented).")] + [InlineData("input-required-result-ignore-extra-params")] + [InlineData("input-required-result-validate-input")] public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package (or installed version uses a stale draft wire string)."); var result = await RunConformanceTestsAsync( $"server --url {fixture.ServerUrl} --scenario {scenario}"); @@ -189,94 +222,20 @@ public async Task RunMrtrConformanceTest(string scenario) private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { - var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); - - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - - var process = new Process { StartInfo = startInfo }; - - // Protect callbacks with try/catch to prevent ITestOutputHelper from - // throwing on a background thread if events arrive after the test completes. - DataReceivedEventHandler outputHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - outputBuilder.AppendLine(e.Data); - } - }; - - DataReceivedEventHandler errorHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - errorBuilder.AppendLine(e.Data); - } - }; - - process.OutputDataReceived += outputHandler; - process.ErrorDataReceived += errorHandler; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - try - { - await process.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - process.Kill(entireProcessTree: true); - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - return ( - Success: false, - Output: outputBuilder.ToString(), - Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed." - ); - } - - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - - var stdoutText = outputBuilder.ToString(); - var stderrText = errorBuilder.ToString(); - - // The Node.js conformance runner can crash during cleanup on Windows with a libuv - // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit - // code even though every conformance check passed. When that happens, fall back to - // parsing the "Test Results:" summary in stdout to decide success. - bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); - - return ( - Success: success, - Output: stdoutText, - Error: stderrText - ); + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + cancellationToken: TestContext.Current.CancellationToken); } - /// - /// Parses the conformance runner output for a "Test Results:" line such as - /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed - /// and none failed. - /// - private static bool ConformanceOutputIndicatesSuccess(string output) + // For draft scenarios that pin --spec-version explicitly, suppress the + // MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended. + private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments) { - // Match lines like "Passed: 3/3, 0 failed, 0 warnings" - var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); - if (!match.Success) - { - return false; - } - - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); - - return passed == total && failed == 0 && total > 0; + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index a06a5d129..7609e8215 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ private async Task StartAsync(ISessionMigrationHandler? migrationHandler = null) Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs index 800a6ce96..bd47bdb74 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -31,7 +31,7 @@ private Task ConnectMcpClientAsync(HttpClient? httpClient = null, Htt [Fact] public async Task ConnectAndReceiveMessage_InMemoryServer() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -84,6 +84,7 @@ public async Task ConnectAndReceiveNotification_InMemoryServer() .WithHttpTransport(httpTransportOptions => { httpTransportOptions.EnableLegacySse = true; + httpTransportOptions.Stateless = false; #pragma warning disable MCPEXP002 // RunSessionHandler is experimental httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => { @@ -128,7 +129,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() { firstOptionsCallbackCallCount++; }) - .WithHttpTransport(options => options.EnableLegacySse = true) + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }) .WithTools(); Builder.Services.AddMcpServer(options => @@ -172,7 +173,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -219,7 +220,7 @@ public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -311,7 +312,7 @@ private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints, b [Fact] public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 517d41e02..963ac765f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -628,7 +628,7 @@ private async Task StartHeaderToolServer() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-test-server", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) @@ -705,7 +705,7 @@ private async Task StartHeaderCapturingServer(Dictionary capture Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 7b282f26d..0b2e393f3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; @@ -36,7 +36,7 @@ private async Task StartAsync() Name = nameof(StreamableHttpServerConformanceTests), Version = "73", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); @@ -894,7 +894,7 @@ public async Task DraftVersion_RejectsMissingMcpMethodHeader() // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -910,7 +910,7 @@ public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -926,7 +926,7 @@ public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -956,19 +956,19 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); AssertServerInfo(rpcResponse); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - SetSessionId(sessionId); + // Draft protocol revision (SEP-2567) is sessionless; the server does not return mcp-session-id. + // Subsequent requests carry MCP-Protocol-Version=2026-07-28 to opt back into the draft path. } private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} """; #endregion diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 15b2c87f2..de1aee820 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,6 +5,7 @@ enable enable Exe + $(NoWarn);MCP9005 diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index f30d58a4d..e0aa5a3a4 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -25,10 +25,28 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // because .NET does not have a built-in concurrent HashSet ConcurrentDictionary> subscriptions = new(); + // Allow running the server in the SEP-2575 stateless lifecycle, which the draft + // "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" + // command-line switch (read via configuration) takes precedence so an in-process test + // fixture can opt in or out per-instance deterministically; when it is not supplied, + // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. + // The default (no switch, no env var) remains the stateful server that serves the + // active conformance suite unchanged. + var statelessConfig = builder.Configuration["stateless"]; + var stateless = statelessConfig is not null + ? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase) + : string.Equals( + Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"), + "true", + StringComparison.OrdinalIgnoreCase); + builder.Services.AddDistributedMemoryCache(); builder.Services .AddMcpServer() - .WithHttpTransport() + // Default (no --stateless switch, no env var) is the stateful server that ConformanceTests + // rely on (resumability, session-scoped subscriptions, OAuth). The "--stateless" switch / env + // var opts into the SEP-2567/SEP-2549 stateless lifecycle for the caching conformance scenario. + .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() .WithTools() .WithTools() @@ -45,6 +63,44 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken); } + return result; + }) + // SEP-2549: advertise TTL/cacheScope caching hints on cacheable results. The + // conformance server's tools, prompts, resources, and resource templates are the + // same for every caller, so they are cacheable with a "public" scope. + .AddListToolsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListPromptsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourcesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddReadResourceFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(1); + result.CacheScope = CacheScope.Public; return result; })) .WithPrompts() diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs index 4dfe6dfb0..0fcb05711 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -16,8 +16,8 @@ namespace ConformanceServer.Prompts; [McpServerPromptType] public sealed class IncompleteResultPrompts { - [McpServerPrompt(Name = "test_incomplete_result_prompt")] - [Description("SEP-2322 D1: prompts/get returns IncompleteResult until user_context is supplied.")] + [McpServerPrompt(Name = "test_input_required_result_prompt")] + [Description("SEP-2322 D1: prompts/get returns InputRequiredResult until user_context is supplied.")] public static GetPromptResult IncompleteResultPrompt(RequestContext context) { if (context.Params!.InputResponses is { } responses && diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs index caf91237a..eb60b7ae1 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -20,8 +20,8 @@ namespace ConformanceServer.Tools; public sealed class IncompleteResultTools { // ──── A1: Basic Elicitation ───────────────────────────────────────────── - [McpServerTool(Name = "test_tool_with_elicitation")] - [Description("SEP-2322 A1: returns IncompleteResult with elicitation/create keyed 'user_name'.")] + [McpServerTool(Name = "test_input_required_result_elicitation")] + [Description("SEP-2322 A1: returns InputRequiredResult with elicitation/create keyed 'user_name'.")] public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -51,8 +51,8 @@ public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -81,8 +81,8 @@ public static CallToolResult ToolWithSampling(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -102,7 +102,7 @@ public static CallToolResult ToolWithListRoots(RequestContext context) { @@ -135,7 +135,7 @@ public static CallToolResult ToolWithRequestState(RequestContext context) { @@ -177,7 +177,7 @@ public static CallToolResult ToolWithMultipleInputs(RequestContext incomplete, R2 -> incomplete (new state), R3 -> complete) ───── - [McpServerTool(Name = "test_incomplete_result_multi_round")] + [McpServerTool(Name = "test_input_required_result_multi_round")] [Description("SEP-2322 B3: three-round flow whose requestState changes between rounds.")] public static CallToolResult ToolWithMultiRound(RequestContext context) { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 75211bb60..f434c6e01 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -425,7 +425,13 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide } builder.Services.AddMcpServer(ConfigureOptions) - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => + { + // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). + // Set Stateless = false explicitly now that draft (SEP-2567) defaults to true. + options.Stateless = false; + options.EnableLegacySse = true; + }); var app = builder.Build(); diff --git a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs new file mode 100644 index 000000000..58ea532e7 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on +/// — the client should call server/discover instead of +/// initialize when is set to +/// . +/// +public class DraftConnectionTests : ClientServerTestBase +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string LatestStableVersion = "2025-11-25"; + + public DraftConnectionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" }; + }); + } + + [Fact] + public async Task DraftClient_ConnectingToDraftServer_NegotiatesDraftVersion() + { + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = DraftVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion); + Assert.NotNull(client.ServerCapabilities); + Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name); + } + + [Fact] + public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() + { + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacyClient_CanCallServerDiscover() + { + // server/discover is registered unconditionally, so a legacy client can probe it + // (e.g., to learn capabilities without doing a second initialize). + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverResult); + Assert.NotEmpty(discoverResult.SupportedVersions); + Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); + Assert.Equal(nameof(DraftConnectionTests), discoverResult.ServerInfo.Name); + } + + [Fact] + public async Task DraftServer_DiscoverIncludesDraftVersion() + { + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverResult); + Assert.Contains(DraftVersion, discoverResult.SupportedVersions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs new file mode 100644 index 000000000..03fedb053 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Verifies that the C# client emits the SEP-2575 _meta envelope on every list-style +/// request (and on server/discover) under the draft protocol revision, even when the +/// caller supplies no RequestOptions / no params. +/// +/// +/// Spec PR #2759 promotes params._meta to required on tools/list, +/// resources/list, resources/templates/list, prompts/list, and +/// server/discover under draft. This test class drives the C# client through +/// with the draft revision negotiated, attaches a request +/// filter on each list endpoint that captures the incoming _meta envelope, and asserts +/// the three required SEP-2575 keys are present: +/// io.modelcontextprotocol/protocolVersion, +/// io.modelcontextprotocol/clientInfo, and +/// io.modelcontextprotocol/clientCapabilities. +/// +public class DraftListMetaEmissionTests : ClientServerTestBase +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + // Captured _meta envelopes for each request method we exercise. Populated by the per-method + // server-side filters and asserted from each test method. + private readonly Dictionary _capturedMeta = new(StringComparer.Ordinal); + + public DraftListMetaEmissionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithRequestFilters(filters => + { + filters.AddListToolsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ToolsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListPromptsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.PromptsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourcesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesTemplatesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + }); + + // No-op list handlers (so the requests complete) — content is irrelevant; we only assert the + // incoming envelope. + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult { Tools = [] })) + .WithListPromptsHandler((_, _) => new ValueTask(new ListPromptsResult { Prompts = [] })) + .WithListResourcesHandler((_, _) => new ValueTask(new ListResourcesResult { Resources = [] })) + .WithListResourceTemplatesHandler((_, _) => new ValueTask( + new ListResourceTemplatesResult { ResourceTemplates = [] })); + } + + [Fact] + public async Task DraftClient_ListTools_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ToolsList); + } + + [Fact] + public async Task DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.PromptsList); + } + + [Fact] + public async Task DraftClient_ListResources_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesList); + } + + [Fact] + public async Task DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesTemplatesList); + } + + [Fact] + public async Task DraftClient_ServerDiscover_EmitsRequiredMeta() + { + // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, + // which still flows through the client's draft-meta injector. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + // Hook the server-side handler invocation via a notification handler is awkward here; assert + // instead by sending the request and parsing the wire-shape echo from the response context. + // Easier path: rely on the existing JsonRpcRequest capture in the message context — see the + // raw conformance tests for the wire-level proof. For this in-process test, we instead drive + // the request and rely on the response being a valid DiscoverResult; the draft meta injector + // would otherwise have failed the server's per-request envelope validation. + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + Assert.NotNull(response.Result); + var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; + Assert.Contains(DraftVersion, discover.SupportedVersions); + + // The server enforces draft envelope shape per request; if the client had omitted _meta, the + // request would have failed with -32602 / -32003 rather than returning a DiscoverResult. The + // successful round-trip is the assertion. + } + + [Fact] + public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta() + { + // Sanity guard: the legacy (non-draft) client must NOT emit the SEP-2575 envelope — the meta + // injector is gated on the negotiated protocol version. If this ever started writing draft keys + // under legacy protocols, every legacy server would reject the request. + StartServer(); + await using var client = await CreateMcpClientForServer(); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var meta = _capturedMeta[RequestMethods.ToolsList]; + if (meta is not null) + { + Assert.False(meta.ContainsKey(NotificationMethods.ProtocolVersionMetaKey)); + Assert.False(meta.ContainsKey(NotificationMethods.ClientInfoMetaKey)); + Assert.False(meta.ContainsKey(NotificationMethods.ClientCapabilitiesMetaKey)); + } + } + + private void AssertDraftMetaPresent(string method) + { + Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}"); + Assert.NotNull(meta); + Assert.True(meta!.ContainsKey(NotificationMethods.ProtocolVersionMetaKey), + $"Missing protocolVersion key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(NotificationMethods.ClientInfoMetaKey), + $"Missing clientInfo key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(NotificationMethods.ClientCapabilitiesMetaKey), + $"Missing clientCapabilities key on {method} _meta envelope"); + + // The protocolVersion value must match the negotiated draft version. + Assert.Equal(DraftVersion, meta[NotificationMethods.ProtocolVersionMetaKey]!.GetValue()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs new file mode 100644 index 000000000..f3ac08861 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs @@ -0,0 +1,208 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path in +/// . These verify that a client configured with +/// McpClientOptions.ProtocolVersion = McpSession.DraftProtocolVersion +/// correctly probes for a draft-aware server with server/discover, falls +/// back to the legacy initialize handshake when the server is legacy, +/// and accepts whatever supported protocol version the legacy server +/// negotiates - including a version different from the one the client +/// originally requested. +/// +/// +/// The originally shipped logic in PerformLegacyInitializeAsync compared +/// the server's response against _options.ProtocolVersion, which under +/// draft is "2026-07-28". When the legacy server downgraded to (say) +/// "2025-06-18", the comparison threw, even though the legacy +/// negotiation succeeded. These tests guard against that regression. +/// +public class DraftProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + [Fact] + public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.ServerDiscoverProbed); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_OnInvalidParams_FallsBackTo_Initialize() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.InvalidParams); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimum() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("minimum", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + { + var ct = TestContext.Current.CancellationToken; + // Server responds with a DIFFERENT version than the one the user pinned. + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() + { + // The peer is modern (returns the spec-defined -32001 HeaderMismatch on the probe). + // Falling back to legacy initialize would just produce another malformed envelope. + // Verify the connect-time logic surfaces the error to the caller instead of falling back. + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.HeaderMismatch); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.True(transport.ServerDiscoverProbed); + Assert.False(transport.LegacyInitializeReceived); + Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); + } + + /// + /// Minimal in-memory transport that simulates a legacy server: rejects + /// server/discover (with a configurable JSON-RPC error code) and + /// responds to initialize with a configurable protocol version. + /// + private sealed class LegacyServerTestTransport( + string serverNegotiatedVersion, + int probeErrorCode = (int)McpErrorCode.MethodNotFound) : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "legacy-server-test-transport"; + + public bool ServerDiscoverProbed { get; private set; } + + public bool LegacyInitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverProbed = true; + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = probeErrorCode, + Message = probeErrorCode == (int)McpErrorCode.MethodNotFound + ? "Method not found" + : "Invalid params", + }, + }); + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: + LegacyInitializeReceived = true; + _ = WriteAsync(new JsonRpcResponse + { + Id = initReq.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = serverNegotiatedVersion, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private sealed class TransportChannel( + Channel incoming, + LegacyServerTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 749ef51eb..9997f6c70 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -590,9 +590,9 @@ public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) [Fact] public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() { - Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Server.ServerOptions.ProtocolVersion = "2026-07-28"; + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2026-07-28" }); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 83f9e610f..3dd944ce5 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -22,7 +22,7 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() } [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 90864d393..2307533a5 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -95,14 +95,14 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal // input resolution failures back to the server. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // The client handler throws during input resolution, so the exception // escapes ResolveInputRequestAsync and surfaces directly to the caller. @@ -130,7 +130,7 @@ public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() // SendMessageAsync should throw InvalidOperationException if the message is a // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -155,7 +155,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); clientOptions.Handlers.SamplingHandler = (request, progress, ct) => @@ -165,7 +165,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Model = "test-model" }); - // Start the client task - it will send initialize and block waiting for response + // Start the client task — it will send server/discover (draft) and block waiting for response var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -175,37 +175,34 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - // Simulate server: read initialize request, respond with experimental version + // Simulate server: read server/discover request, respond with a DiscoverResult + // that advertises support for the experimental version. var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Read the initialize request from client - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); + // Read the server/discover request from client (draft revision skips initialize per SEP-2575). + var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(discoverLine); + var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverRequest); + Assert.Equal(RequestMethods.ServerDiscover, discoverRequest.Method); - // Respond with experimental protocol version (MRTR negotiated) - var initResponse = new JsonRpcResponse + // Respond with a DiscoverResult that includes the experimental version in supportedVersions. + var discoverResponse = new JsonRpcResponse { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult { - ProtocolVersion = "DRAFT-2026-v1", + SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); + await WriteJsonRpcAsync(serverWriter, discoverResponse); - // Client is now connected with MRTR negotiated + // Client is now connected with MRTR negotiated (no initialized notification under draft). await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // Now simulate the non-compliant server sending a legacy elicitation/create request var legacyRequest = new JsonRpcRequest @@ -253,7 +250,7 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Client does NOT set DRAFT-2026-v1 - standard protocol only + // Client does NOT set 2026-07-28 - standard protocol only var clientOptions = new McpClientOptions(); clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult @@ -431,7 +428,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR. + // Initialize handshake - negotiate 2026-07-28 so the client treats InputRequiredResult as MRTR. var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); Assert.NotNull(initLine); var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); @@ -443,7 +440,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro Id = initRequest.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ServerCapabilities { Tools = new() }, ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), @@ -454,7 +451,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro Assert.NotNull(initializedLine); await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 1956887ac..6790489c3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -79,7 +79,7 @@ private async Task AssertNoMatchAsync( // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). [Theory] [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] - [InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)] + [InlineData("2026-07-28", McpErrorCode.InvalidParams)] public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode) { var resource = McpServerResource.Create( diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index a9b40a412..cb9b77140 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -11,7 +11,7 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902 + $(NoWarn);NU1903;NU1902;MCP9005 $(DefineConstants);MCP_TEST_TIME_PROVIDER diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs new file mode 100644 index 000000000..38e690d07 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results +/// are observed by a connected client. +/// +public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper) + : ClientServerTestBase(testOutputHelper) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult + { + Tools = [new Tool { Name = "echo" }], + TimeToLive = TimeSpan.FromMinutes(5), + CacheScope = CacheScope.Public, + })) + .WithReadResourceHandler((request, _) => new ValueTask(new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = request.Params!.Uri!, Text = "hi" }], + TimeToLive = TimeSpan.FromSeconds(30), + CacheScope = CacheScope.Private, + })); + } + + [Fact] + public async Task ListTools_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListToolsAsync( + new ListToolsRequestParams(), + TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromMinutes(5), result.TimeToLive); + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + [Fact] + public async Task ReadResource_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync( + "test://resource", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs new file mode 100644 index 000000000..aba38bfa0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs @@ -0,0 +1,292 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Tests for the SEP-2549 caching hints (ttlMs and cacheScope) carried by +/// implementations: the results of tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. +/// +public static class CacheableResultTests +{ + public static IEnumerable CacheableResultTypes() + { + yield return new object[] { typeof(ListToolsResult) }; + yield return new object[] { typeof(ListPromptsResult) }; + yield return new object[] { typeof(ListResourcesResult) }; + yield return new object[] { typeof(ListResourceTemplatesResult) }; + yield return new object[] { typeof(ReadResourceResult) }; + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_SerializesTtlMsAsIntegerMilliseconds(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PrivateScope_RoundTrips(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // A TTL of zero is meaningful (immediately stale) and must still be emitted. + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_OmitsCachingHints_WhenUnset(Type type) + { + object result = Activator.CreateInstance(type)!; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMissingHints_AsNull(Type type) + { + // A response from a server that predates SEP-2549 contains neither field. + string json = "{}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeTtl(Type type) + { + // Per SEP-2549, a negative ttlMs is preserved on the DTO; callers SHOULD treat it as zero. + string json = "{\"ttlMs\":-5,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(-5), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesOversizedTtl_ClampsInsteadOfThrowing(Type type) + { + // A hostile or buggy server could return a ttlMs that is a valid JSON integer but exceeds the + // range representable by TimeSpan. Deserialization must not throw (which would break reading the + // entire list); the value is clamped to TimeSpan.MaxValue instead. + string json = "{\"ttlMs\":9999999999999999,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MaxValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesLargeNegativeTtl_ClampsToMinValue(Type type) + { + string json = "{\"ttlMs\":-9999999999999999}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MinValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMaxRepresentableTtl_DoesNotThrow(Type type) + { + // The largest whole-millisecond count that fits in a TimeSpan must round-trip without clamping. + long maxWholeMs = long.MaxValue / TimeSpan.TicksPerMillisecond; + string json = $"{{\"ttlMs\":{maxWholeMs}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks(maxWholeMs * TimeSpan.TicksPerMillisecond), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesHugeFloatTtl_ClampsInsteadOfThrowing(Type type) + { + // A fractional/exponent ttlMs whose tick count overflows to +Infinity is clamped to MaxValue + // rather than throwing. (1e400 is beyond double range, so GetDouble() itself returns +Infinity.) + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e400}")); + + // 1e308 is finite but overflows once scaled into tick-space. + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e308}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeInfinityFloatTtl_ClampsToMinValue(Type type) + { + // A large negative exponent ttlMs yields -Infinity from GetDouble(); it must clamp to MinValue, + // not silently become long.MinValue ticks via the cast. + Assert.Equal( + TimeSpan.MinValue, + DeserializeTtl(type, "{\"ttlMs\":-1e400}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesUnknownCacheScope_AsNull(Type type) + { + // A future/unknown cacheScope string must not break deserialization of the entire result; it is + // tolerated and surfaced as null (equivalent to an absent field, which clients treat as public). + // Non-string tokens, including objects and arrays, must likewise be tolerated and fully consumed. + foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null", "{}", "[]", "{\"a\":1}", "[1,2]" }) + { + string json = $"{{\"cacheScope\":{scope}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScope_CaseInsensitively(Type type) + { + // Casing of the security-relevant "private" hint must be honored rather than silently dropped to + // null (which clients treat as public), so matching is case-insensitive on read. + foreach (string scope in new[] { "PUBLIC", "Public", "pUbLiC" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + foreach (string scope in new[] { "PRIVATE", "Private", "pRiVaTe" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Private, result.CacheScope); + } + } + + private static TimeSpan? DeserializeTtl(Type type, string json) => + ((ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!).TimeToLive; + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesFractionalTtl(Type type) + { + string json = "{\"ttlMs\":1.5}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks((long)(1.5 * TimeSpan.TicksPerMillisecond)), deserialized.TimeToLive); + } + + [Fact] + public static void CacheScope_SerializesAsLowercaseStrings() + { + Assert.Equal("\"public\"", JsonSerializer.Serialize(CacheScope.Public, McpJsonUtilities.DefaultOptions)); + Assert.Equal("\"private\"", JsonSerializer.Serialize(CacheScope.Private, McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Public, JsonSerializer.Deserialize("\"public\"", McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize("\"private\"", McpJsonUtilities.DefaultOptions)); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesTtlWithoutCacheScope(Type type) + { + // ttlMs present, cacheScope absent: the SEP says an absent scope defaults to "public", + // but the SDK only propagates the wire value, so the DTO reports null (caller applies default). + string json = "{\"ttlMs\":1000}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromSeconds(1), deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScopeWithoutTtl(Type type) + { + // cacheScope present, ttlMs absent: a server may classify cacheability without a freshness hint. + string json = "{\"cacheScope\":\"private\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PaginatedPages_CarryIndependentCachingHints(Type type) + { + // SEP-2549: each paginated page independently carries its own ttlMs/cacheScope. + // Two result instances representing consecutive pages must round-trip distinct hints. + var page1 = (ICacheableResult)Activator.CreateInstance(type)!; + page1.TimeToLive = TimeSpan.FromMinutes(10); + page1.CacheScope = CacheScope.Public; + + var page2 = (ICacheableResult)Activator.CreateInstance(type)!; + page2.TimeToLive = TimeSpan.FromSeconds(5); + page2.CacheScope = CacheScope.Private; + + var rt1 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page1, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + var rt2 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page2, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.FromMinutes(10), rt1.TimeToLive); + Assert.Equal(CacheScope.Public, rt1.CacheScope); + Assert.Equal(TimeSpan.FromSeconds(5), rt2.TimeToLive); + Assert.Equal(CacheScope.Private, rt2.CacheScope); + } + + [Fact] + public static void CacheableResult_MaxValueTtl_WriteThenRead_IsStableAcrossRoundTrips() + { + // Writing TimeSpan.MaxValue truncates the sub-millisecond remainder to a whole-millisecond + // integer (922337203685477 ms), so the first round-trip is slightly less than MaxValue. + // Critically, once written this value is a fixed point: further round-trips do not drift. + var first = RoundTrip(new ListToolsResult { TimeToLive = TimeSpan.MaxValue }); + var second = RoundTrip(new ListToolsResult { TimeToLive = first.TimeToLive }); + + Assert.NotEqual(TimeSpan.MaxValue, first.TimeToLive); + Assert.Equal(first.TimeToLive, second.TimeToLive); + } + + private static ListToolsResult RoundTrip(ListToolsResult result) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!; +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs new file mode 100644 index 000000000..48bbae157 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -0,0 +1,80 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575). +/// +public static class DiscoverProtocolTests +{ + [Fact] + public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta() + { + var original = new DiscoverRequestParams + { + Meta = new JsonObject + { + [NotificationMethods.ProtocolVersionMetaKey] = "2026-07-28", + [NotificationMethods.ClientInfoMetaKey] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0", + }, + [NotificationMethods.ClientCapabilitiesMetaKey] = new JsonObject(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Meta); + Assert.Equal("2026-07-28", (string)deserialized.Meta[NotificationMethods.ProtocolVersionMetaKey]!); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new DiscoverResult + { + SupportedVersions = new List { "2025-11-25", "2026-07-28" }, + Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability { ListChanged = true }, + }, + ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, + Instructions = "Use this server for testing.", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions); + Assert.NotNull(deserialized.Capabilities.Tools); + Assert.True(deserialized.Capabilities.Tools.ListChanged); + Assert.Equal("test-server", deserialized.ServerInfo.Name); + Assert.Equal("Use this server for testing.", deserialized.Instructions); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties() + { + var original = new DiscoverResult + { + SupportedVersions = new List { "2026-07-28" }, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.SupportedVersions); + Assert.Equal("2026-07-28", deserialized.SupportedVersions[0]); + Assert.Null(deserialized.Instructions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs new file mode 100644 index 000000000..001c05f95 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -0,0 +1,125 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Targeted tests for the SEP-2549 caching hints (ttlMs and cacheScope) on +/// . Spec PR #2855 promotes both fields to required on the discover +/// response. has required CLR properties for +/// , , and +/// , which prevents reuse of the parameterized +/// helper (it instantiates via reflection). This file covers the +/// same property-shape assertions for . +/// +public static class DiscoverResultCacheableTests +{ + private static DiscoverResult NewDiscoverResult() => new() + { + SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, + }; + + [Fact] + public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_PrivateScope_RoundTrips() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_OmitsCachingHints_WhenUnset() + { + var result = NewDiscoverResult(); + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesMissingHints_AsNull() + { + // A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed + // and surface them as null so callers can apply their own defaults. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"} + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull() + { + // A future or unknown cacheScope string must not break deserialization of the entire result. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"}, + "cacheScope": "shared" + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_ImplementsICacheableResult() + { + // Compile-time assertion that DiscoverResult participates in the shared cacheability surface + // alongside the list/read result types. + Assert.IsAssignableFrom(NewDiscoverResult()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs new file mode 100644 index 000000000..fd82f9e0b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575). +/// +public static class DraftErrorDataTests +{ + [Fact] + public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new UnsupportedProtocolVersionErrorData + { + Supported = new List { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" }, + Requested = "2026-07-28", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(4, deserialized.Supported.Count); + Assert.Contains("2025-11-25", deserialized.Supported); + Assert.Equal("2026-07-28", deserialized.Requested); + } + + [Fact] + public static void MissingRequiredClientCapabilityErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.RequiredCapabilities.Sampling); + } + + [Fact] + public static void UnsupportedProtocolVersionException_ExposesRequestedAndSupported() + { + var ex = new UnsupportedProtocolVersionException("2099-12-31", ["2025-11-25", "2025-06-18"]); + + Assert.Equal(McpErrorCode.UnsupportedProtocolVersion, ex.ErrorCode); + Assert.Equal("2099-12-31", ex.Requested); + Assert.Equal(2, ex.Supported.Count); + Assert.Contains("2025-11-25", ex.Supported); + } + + [Fact] + public static void MissingRequiredClientCapabilityException_ExposesRequiredCapabilities() + { + var caps = new ClientCapabilities { Roots = new RootsCapability() }; + var ex = new MissingRequiredClientCapabilityException(caps); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, ex.ErrorCode); + Assert.Same(caps, ex.RequiredCapabilities); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs index ddab6b142..b8061cc19 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs @@ -760,4 +760,36 @@ public static void Deserialize_ErrorWithArrayData_IsValid() var error = (JsonRpcError)message; Assert.NotNull(error.Error.Data); } + + [Fact] + public static void Deserialize_ErrorWithNullId_IsValid() + { + // Per JSON-RPC 2.0 §5.1, when an error occurs before the request id can be determined + // (parse error or invalid request), the server MUST respond with id=null. This shape is + // produced by some peers (e.g. Python's simple-streamablehttp-stateless on a draft probe) + // and must be accepted so the HTTP-fallback path can recognize the structured signal. + string json = """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32600, error.Error.Code); + Assert.Equal("Bad Request", error.Error.Message); + } + + [Fact] + public static void Deserialize_ErrorWithMissingId_IsValid() + { + // Some peers omit `id` entirely on pre-routing errors; treat as null per JSON-RPC 2.0 §5.1. + string json = """{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32700, error.Error.Code); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs index e426c7469..8ef150fe6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs @@ -35,4 +35,15 @@ public void Int64Ctor_Roundtrips() Assert.Equal(id, JsonSerializer.Deserialize(JsonSerializer.Serialize(id, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)); } + + [Fact] + public void Null_DeserializesAsDefault() + { + // Per JSON-RPC 2.0 §5.1, error responses produced before the request id can be determined + // MUST carry id=null. Deserialization needs to tolerate that shape so callers can handle + // such error envelopes (instead of throwing on the bare RequestId conversion). + var id = JsonSerializer.Deserialize("null", McpJsonUtilities.DefaultOptions); + Assert.Equal(default(RequestId), id); + Assert.Null(id.Id); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs new file mode 100644 index 000000000..597d242e9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs @@ -0,0 +1,63 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the subscriptions/listen types introduced by the draft protocol revision (SEP-2575). +/// +public static class SubscriptionsListenProtocolTests +{ + [Fact] + public static void SubscriptionsListenRequestParams_SerializationRoundTrip_PreservesAllProperties() + { + var original = new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + PromptsListChanged = true, + ResourcesListChanged = true, + ResourceSubscriptions = new List { "file:///foo.txt", "file:///bar.txt" }, + }, + Meta = new JsonObject + { + [NotificationMethods.ProtocolVersionMetaKey] = "2026-07-28", + [NotificationMethods.LogLevelMetaKey] = "info", + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.True(deserialized.Notifications.PromptsListChanged); + Assert.True(deserialized.Notifications.ResourcesListChanged); + Assert.NotNull(deserialized.Notifications.ResourceSubscriptions); + Assert.Equal(["file:///foo.txt", "file:///bar.txt"], deserialized.Notifications.ResourceSubscriptions); + Assert.Equal("2026-07-28", (string)deserialized.Meta![NotificationMethods.ProtocolVersionMetaKey]!); + } + + [Fact] + public static void SubscriptionsAcknowledgedNotificationParams_SerializationRoundTrip_PreservesNotifications() + { + var original = new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.Null(deserialized.Notifications.PromptsListChanged); + Assert.Null(deserialized.Notifications.ResourcesListChanged); + Assert.Null(deserialized.Notifications.ResourceSubscriptions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs index 662ffdb27..ca6134a24 100644 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -9,15 +9,15 @@ namespace ModelContextProtocol.Tests.Server; /// Verifies that the server-to-client request methods (, /// , /// ) keep working when the negotiated protocol revision is -/// DRAFT-2026-v1 on a stateful session - for example, stdio. +/// 2026-07-28 on a stateful session - for example, stdio. /// /// -/// Under DRAFT-2026-v1 the spec removes the corresponding server-to-client request methods, but +/// Under 2026-07-28 the spec removes the corresponding server-to-client request methods, but /// the SDK only fails fast in stateless mode (where the existing ThrowIf*Unsupported guards already /// throw "X is not supported in stateless mode" because is /// ). Stdio is implicitly stateful - one per process - so the /// legacy elicitation/create / sampling/createMessage / roots/list flow still works. -/// A future PR is expected to force DRAFT-2026-v1 Streamable HTTP servers to stateless mode, at which +/// A future PR is expected to force 2026-07-28 Streamable HTTP servers to stateless mode, at which /// point those configurations will start throwing through the existing stateless guard. /// public sealed class DraftProtocolBackcompatTests : ClientServerTestBase @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); mcpServerBuilder.WithTools([ @@ -47,7 +47,7 @@ public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability(), @@ -69,7 +69,7 @@ public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Sampling = new SamplingCapability(), @@ -96,7 +96,7 @@ public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequ StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Roots = new RootsCapability(), diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs index 9a408ce78..d4839f58a 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -191,7 +191,7 @@ public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCance StartServer(); var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // Cancel the token during the callback. The retry loop will throw @@ -219,7 +219,7 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() StartServer(); var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = async (request, ct) => { // Signal that the MRTR round trip reached the client, then block indefinitely. @@ -263,7 +263,7 @@ public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandle // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -301,7 +301,7 @@ public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHand StartServer(); int elicitationCount = 0; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { Interlocked.Increment(ref elicitationCount); @@ -345,7 +345,7 @@ public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() StartServer(); bool handlerCompleted = false; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -387,7 +387,7 @@ public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() // (after resuming from ElicitAsync), the error is logged at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -417,7 +417,7 @@ public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() // not an error. It should not be logged via ToolCallError at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index 664429b13..a263e289b 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -23,7 +23,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs index fd9098734..9c83a3306 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -26,7 +26,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -73,14 +73,14 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() // When both sides are on the experimental protocol, the server should use MRTR // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { return new ValueTask(new ElicitResult { Action = "accept" }); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("elicit-tool", new Dictionary { ["message"] = "test" }, @@ -95,7 +95,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() { StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.SamplingHandler = (request, progress, ct) => { var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; @@ -107,7 +107,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("sample-tool", new Dictionary { ["prompt"] = "test" }, @@ -126,7 +126,7 @@ public async Task OutgoingFilter_SeesIncompleteResultResponse() var sawIncompleteResult = false; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // If we reach this handler, it means the client received an InputRequiredResult diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index d8fa6f32b..14b5dd3a9 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. -/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful - +/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful - /// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler /// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks /// a non-draft version during initialize negotiation. diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs index 1836d4d13..fc9c26fc2 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -49,7 +49,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); // Outgoing filter: detect InputRequiredResult responses and track per session. @@ -131,7 +131,7 @@ public async Task OutgoingFilter_TracksIncompleteResultsPerSession() // Verify that an outgoing message filter can observe InputRequiredResult responses // and track the pending MRTR flow count per session using context.Server.SessionId. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -165,7 +165,7 @@ public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() _maxFlowsPerSession = 0; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs new file mode 100644 index 000000000..45c978be2 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the built-in ping handler is gated by protocol version. +/// SEP-2575 (the draft 2026-07-28 revision) removes ping; servers must +/// respond with -32601 MethodNotFound. Legacy protocol versions still +/// support ping per the spec. +/// +public sealed class PingProtocolGatingTests : ClientServerTestBase +{ + public PingProtocolGatingTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + } + + [Fact] + public async Task Ping_OnDraftSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Ping_OnLegacySession_StillSucceeds() + { + // Default server config; client pinned to 2025-11-25. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }); + + var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs new file mode 100644 index 000000000..ccc87d9d3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Wire-format conformance tests for driven directly against the underlying +/// stream — without going through . This exercises the +/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC +/// messages and asserting on the exact responses the server emits. +/// +/// +/// The tests use a paired the way does, but instead +/// of constructing an McpClient we read and write JSON-RPC envelopes directly. This is the closest +/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise. +/// +public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private readonly Pipe _clientToServer = new(); + private readonly Pipe _serverToClient = new(); + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly Task _serverTask; + private readonly ServiceProvider _services; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + + public RawStreamConformanceTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(XunitLoggerProvider)); + services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" }; + }) + .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) + .WithTools([ + McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }), + ]); + + _services = services.BuildServiceProvider(validateScopes: true); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); + + _writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + _reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _clientToServer.Writer.Complete(); + _serverToClient.Writer.Complete(); + try { await _serverTask; } catch { /* expected on cancellation */ } + await _services.DisposeAsync(); + _cts.Dispose(); + Dispose(); + } + + private async Task SendAsync(string json) => await _writer.WriteLineAsync(json); + + private async Task ReadAsync() + { + var line = await _reader.ReadLineAsync().WaitAsync(_cts.Token); + Assert.NotNull(line); + return JsonNode.Parse(line!)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() + { + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal("2.0", response["jsonrpc"]!.GetValue()); + Assert.Equal(1, response["id"]!.GetValue()); + + var result = response["result"]; + Assert.NotNull(result); + + var supportedVersions = result!["supportedVersions"]!.AsArray() + .Select(n => n!.GetValue()) + .ToList(); + Assert.Contains(DraftVersion, supportedVersions); + + // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. + Assert.NotNull(result["capabilities"]); + Assert.NotNull(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the + // safest defaults (immediately stale, not shareable) when the application hasn't customized. + Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind()); + Assert.Equal(0, result["ttlMs"]!.GetValue()); + Assert.Equal("private", result["cacheScope"]!.GetValue()); + } + + [Fact] + public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided() + { + // Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long + // as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," + + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal(42, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + var content = result!["content"]!.AsArray(); + Assert.Single(content); + Assert.Equal("echo:hello", content[0]!["text"]!.GetValue()); + } + + [Fact] + public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported() + { + // Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"); + + var response = await ReadAsync(); + Assert.Equal(7, response["id"]!.GetValue()); + var error = response["error"]; + Assert.NotNull(error); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue()); + + var data = error["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer() + { + // Dual-era: a draft-default server (ProtocolVersion = DraftVersion in McpServerOptions) must still + // accept the legacy initialize handshake from clients that don't speak the new protocol. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + + var response = await ReadAsync(); + Assert.Equal(1, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + { + // Dual-era servers must accept draft and legacy traffic on the same connection. The exact mix below + // is what a permissive client running against an unknown server would emit while probing. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + var discover = await ReadAsync(); + Assert.NotNull(discover["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.NotNull(init["result"]); + Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); + + await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}"); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}"); + var call = await ReadAsync(); + Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); + } +}