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..2168a300a 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,12 @@ 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`**. The C# SDK client prefers the draft revision by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support draft. Servers accept the draft automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. ```csharp -// Client +// Client — the SDK prefers the 2026-07-28 draft (and therefore MRTR) by default. var clientOptions = new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", Handlers = new McpClientHandlers { ElicitationHandler = HandleElicitationAsync, @@ -48,7 +47,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 is stateless-only under the draft 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 +59,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 +70,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 +257,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 +269,18 @@ 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. - -This work is a follow-up to the present PR. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks the draft. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves draft requests sessionlessly and creates a legacy session only when an older client falls back to `initialize`. 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..861a1cd6b 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -7,15 +7,15 @@ uid: stateless # Stateless and stateful mode -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). -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)). +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 **Quick guide — which mode should I use?** -- Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** +- Does your server need to send requests _to_ the client (elicitation, sampling, roots) and can't rely on clients supporting [MRTR](xref:mrtr)? → **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.** @@ -24,19 +24,44 @@ When sessions are enabled (the current C# SDK default), the server creates and t > [!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. +> **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` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The 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: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` draft revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` draft and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`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. +- **`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. 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. +- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` draft, however, so keep a session if you need server-to-client requests against clients that don't speak the draft. Note that even with `Stateless = false`, draft requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision. > [!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. +> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + +### The 2026-07-28 draft revision + +The `2026-07-28` 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). + +**Server side.** With `Stateless = true` (the default), the SDK already meets the draft on the wire. Any HTTP POST that arrives with the draft `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a sessionless draft request on a stateful server is refused with a `-32004 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A draft request that carries an `Mcp-Session-Id` is always rejected, since the draft revision has no session concept. + +**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 `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. + +**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it: + +- **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 +var clientOptions = new McpClientOptions +{ + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, +}; +``` ### Migrating from legacy SSE @@ -93,7 +118,7 @@ When - [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. + [MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak the `2026-07-28` draft — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). - **[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. @@ -115,17 +140,13 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, ### Stateless alternatives for server-to-client interactions - -> [!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. +The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](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. - -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 ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` draft, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak the draft. ## 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 @@ -154,7 +175,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | | **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) | -| **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | +| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (draft-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) | | **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | @@ -396,14 +417,16 @@ builder.Services.AddMcpServer() | 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 (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | +| | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | +| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all 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 (`MCP9006`)._ Enables cross-instance session migration. Can also be registered in DI. | +| | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | +| | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | + +The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served 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. ### ConfigureSessionOptions 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 414118831..9bf6c982c 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -1,3 +1,7 @@ +--- +uid: list-of-diagnostics +--- + # List of Diagnostics Produced by MCP C# SDK This document provides information about each of the diagnostics produced by the MCP C# SDK analyzers and source generators. @@ -40,3 +44,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. | +| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `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 6dfdc15f2..542fddb8d 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -42,4 +42,8 @@ internal static class Obsoletions public const string DeprecatedRoots_Message = "The Roots feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; public const string DeprecatedSampling_Message = "The Sampling feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; public const string DeprecatedLogging_Message = "The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; + + public const string LegacyStatefulHttp_DiagnosticId = "MCP9006"; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs index 9433eea7e..c3293a5c6 100644 --- a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs @@ -4,6 +4,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // This type only exists to configure the obsolete legacy resumability store. + /// /// Configures by resolving /// the from DI when not explicitly set. diff --git a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs index 1b4786163..47c51f07a 100644 --- a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs +++ b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs @@ -5,6 +5,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // This type only exists to validate the obsolete legacy resumability store options. + /// /// Validates that is set. /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index bcdf53584..732821baa 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -84,6 +84,8 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b /// set the property in the callback. /// /// + [Obsolete(ModelContextProtocol.Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = ModelContextProtocol.Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = ModelContextProtocol.Obsoletions.LegacyStatefulHttp_Url)] +#pragma warning disable MCP9006 // The method is itself obsolete and intentionally wires up the legacy resumability store. public static IMcpServerBuilder WithDistributedCacheEventStreamStore(this IMcpServerBuilder builder, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(builder); @@ -99,4 +101,5 @@ public static IMcpServerBuilder WithDistributedCacheEventStreamStore(this IMcpSe return builder; } +#pragma warning restore MCP9006 } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 648cb86df..b24bb6f17 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,16 @@ 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. + /// + /// The 2026-07-28 draft protocol revision is sessionless and removes Mcp-Session-Id entirely + /// (SEP-2567), so over HTTP draft requests are only ever served when . When this + /// property is , a sessionless draft request is refused with a + /// -32004 UnsupportedProtocolVersion error so that a dual-era client downgrades to the legacy + /// initialize handshake and obtains the session that the server was configured to provide. A draft + /// request that carries an Mcp-Session-Id is always rejected, regardless of this property's value. + /// /// - 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 +122,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 +139,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 +156,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 +175,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 +196,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..b5fad97a7 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 MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. options.EventStreamStore ??= serviceProvider.GetService(); options.SessionMigrationHandler ??= serviceProvider.GetService(); +#pragma warning restore MCP9006 } } diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index 645253d6f..b11fe81cd 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -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 MCP9006 // 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 MCP9006 _sessions = sessions; _options = options; diff --git a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs index 7c6970c70..f17df37a1 100644 --- a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs @@ -7,6 +7,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // These extensions only operate on the obsolete legacy resumability reader. + /// /// Provides extension methods for . /// diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs index 880bd04a5..06573ec9f 100644 --- a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs +++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; @@ -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 MCP9006 // 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 MCP9006 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..0b1a9df60 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; @@ -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,9 +40,17 @@ internal sealed class StreamableHttpHandler( "2025-03-26", "2025-06-18", "2025-11-25", - "DRAFT-2026-v1", + McpHttpHeaders.DraftProtocolVersion, ]; + /// + /// The supported protocol versions excluding the draft revision. Used when refusing a sessionless + /// draft request on a stateful (Stateless = false) server so a dual-era client falls back to a + /// legacy initialize handshake instead of retrying the draft version. + /// + private static readonly string[] s_supportedProtocolVersionsExcludingDraft = + [.. s_supportedProtocolVersions.Where(static v => !string.Equals(v, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal))]; + private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -54,9 +63,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; } @@ -73,16 +82,31 @@ await WriteJsonRpcErrorAsync(context, return; } - var message = await ReadJsonRpcMessageAsync(context); + JsonRpcMessage? message; + try + { + message = await ReadJsonRpcMessageAsync(context); + } + catch (JsonException) + { + // The POST body was not a well-formed JSON-RPC message (malformed JSON, or a request whose + // id was explicitly null, which MCP forbids). Surface a conformant JSON-RPC error response + // with a null id rather than letting the exception bubble up as an opaque 500. + await WriteJsonRpcErrorAsync(context, + "Bad Request: The POST body did not contain a valid JSON-RPC message.", + StatusCodes.Status400BadRequest, (int)McpErrorCode.InvalidRequest); + return; + } + if (message is null) { await WriteJsonRpcErrorAsync(context, "Bad Request: The POST body did not contain a valid JSON-RPC message.", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, (int)McpErrorCode.InvalidRequest); 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 +132,23 @@ 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 the standalone HTTP GET endpoint for unsolicited + // server-to-client messages is removed (SEP-2575); clients use subscriptions/listen (POST) + // instead. The draft is also sessionless (SEP-2567), so a draft GET is invalid whether or + // not it carries an Mcp-Session-Id. + if (IsDraftProtocolRequest(context)) + { + 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 +160,6 @@ await WriteJsonRpcErrorAsync(context, return; } - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); var session = await GetSessionAsync(context, sessionId); if (session is null) { @@ -203,7 +240,9 @@ await WriteJsonRpcErrorAsync(context, } } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private static async Task HandleResumePostResponseStreamAsync(HttpContext context, ISseEventStreamReader eventStreamReader) +#pragma warning restore MCP9006 { InitializeSseResponse(context); await eventStreamReader.CopyToAsync(context.Response.Body, context.RequestAborted); @@ -211,13 +250,24 @@ 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 (SEP-2567). A draft DELETE is + // invalid whether or not it carries an Mcp-Session-Id. + if (IsDraftProtocolRequest(context)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (the draft protocol is sessionless).", + StatusCodes.Status400BadRequest); + return; + } + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) { return; @@ -280,10 +330,12 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask TryMigrateSessionAsync(HttpContext context, string sessionId) { +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler) { return null; } +#pragma warning restore MCP9006 var migrationLock = _migrationLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); await migrationLock.WaitAsync(context.RequestAborted); @@ -319,6 +371,34 @@ 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, the draft is sessionless: SEP-2567 removes the + // Mcp-Session-Id header (and the session concept) and SEP-2575 removes the initialize + // handshake. So over HTTP, draft <=> sessionless, with no exceptions: + if (isDraftRequest) + { + if (!string.IsNullOrEmpty(sessionId)) + { + // A draft request carrying an Mcp-Session-Id is non-conformant (SEP-2567). + await WriteJsonRpcErrorAsync(context, + "Bad Request: Mcp-Session-Id is not supported under the draft protocol revision; the draft protocol is sessionless (SEP-2567).", + StatusCodes.Status400BadRequest); + return null; + } + + if (HttpServerTransportOptions.Stateless) + { + // The default (stateless) HTTP transport serves sessionless draft requests natively. + return await StartNewSessionAsync(context); + } + + // The author explicitly opted into sessions (Stateless = false), which the draft revision + // cannot provide. Refuse the draft version so a dual-era client falls back to the legacy + // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + await WriteUnsupportedDraftVersionErrorAsync(context); + return null; + } if (string.IsNullOrEmpty(sessionId)) { @@ -350,14 +430,28 @@ await WriteJsonRpcErrorAsync(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) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + bool isStateless = HttpServerTransportOptions.Stateless; + + if (!isStateless) { sessionId = MakeNewSessionId(); +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. transport = new(loggerFactory) { SessionId = sessionId, @@ -367,12 +461,13 @@ private async ValueTask StartNewSessionAsync(HttpContext ? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct) : null, }; +#pragma warning restore MCP9006 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 = ""; @@ -393,11 +488,13 @@ private async ValueTask CreateSessionAsync( { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + bool effectivelyStateless = HttpServerTransportOptions.Stateless; + + 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 +531,10 @@ private async ValueTask MigrateSessionAsync( var transport = new StreamableHttpServerTransport(loggerFactory) { SessionId = sessionId, +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext, EventStreamStore = HttpServerTransportOptions.EventStreamStore, +#pragma warning restore MCP9006 }; // Initialize the transport with the migrated session's init params. @@ -450,9 +549,11 @@ private async ValueTask MigrateSessionAsync( }); } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private async ValueTask GetEventStreamReaderAsync(HttpContext context, string lastEventId) { if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore) +#pragma warning restore MCP9006 { await WriteJsonRpcErrorAsync(context, "Bad Request: This server does not support resuming streams.", @@ -559,22 +660,60 @@ 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; } + /// + /// Refuses a sessionless draft request on a stateful (Stateless = false) server. The draft revision + /// is sessionless (SEP-2567) and cannot honor the author's opt-in to sessions, so we return + /// with a supported-versions list that excludes + /// the draft. A dual-era client then falls back to the legacy initialize handshake (SEP-2575). + /// + private static Task WriteUnsupportedDraftVersionErrorAsync(HttpContext context) + { + var errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = $"Bad Request: The draft protocol revision '{McpHttpHeaders.DraftProtocolVersion}' is sessionless and is not supported when the server is configured with sessions (HttpServerTransportOptions.Stateless = false). " + + "Use the initialize handshake with a supported non-draft protocol version instead.", + Data = JsonSerializer.SerializeToNode( + new UnsupportedProtocolVersionErrorData + { + Supported = s_supportedProtocolVersionsExcludingDraft, + Requested = McpHttpHeaders.DraftProtocolVersion, + }, + GetRequiredJsonTypeInfo()), + }; + + return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest); + } + /// /// Validates standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers /// (Mcp-Param-*) against the JSON-RPC request body. @@ -640,6 +779,23 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess var mcpNameInHeader = context.Request.Headers[McpHttpHeaders.Name].ToString().Trim(); + // Per SEP-2243, non-ASCII Mcp-Name values MUST be Base64-encoded using the + // "=?base64?...?=" wrapper. Reject raw values containing characters outside the valid + // HTTP header value range, then decode so the comparison below is against the + // decoded value (mirrors the Mcp-Param-* validation in ValidateCustomParamHeaders). + if (!IsValidHeaderValue(mcpNameInHeader)) + { + errorMessage = "Header mismatch: Mcp-Name header contains invalid characters."; + return false; + } + + var decodedMcpNameInHeader = McpHeaderEncoder.DecodeValue(mcpNameInHeader); + if (decodedMcpNameInHeader is null) + { + errorMessage = "Header mismatch: Mcp-Name header contains invalid Base64 encoding."; + return false; + } + // Extract the params and name value from the body based on the method, if present. var bodyParams = message switch { @@ -656,7 +812,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess }; // Check that the header value matches the body value if the body value is present. - if (!string.Equals(mcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) + if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) { errorMessage = $"Header mismatch: Mcp-Name header value '{mcpNameInHeader}' does not match body value '{mcpNameInBody}'."; return false; @@ -997,6 +1153,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..3247dd8a7 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Net; +using System.Net.Http; using System.Threading.Channels; namespace ModelContextProtocol.Client; @@ -73,19 +74,37 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; } + else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) + { + // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server + // — it just rejected our specific request (e.g., -32004 UnsupportedProtocolVersion, + // -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other + // application-level error). Don't fall back to SSE — that would mask the real signal + // and surface a misleading "session id required" error from the SSE GET path. + // Adopt the Streamable HTTP transport and throw the structured exception so the + // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport + // first makes the catch filter below leave the now-owned transport alone. + LogUsingStreamableHttp(_name); + ActiveTransport = streamableHttpTransport; + throw 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; } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 22245a924..218cd55d0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1174,7 +1174,10 @@ public async ValueTask> CallToolRawAsync( { Name = requestParams.Name, Arguments = requestParams.Arguments, - Meta = GetMetaWithTaskCapability(requestParams.Meta), + // The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call + // (no task capability envelope) so the server returns a direct CallToolResult and never + // creates a task. + Meta = IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, }; JsonRpcRequest jsonRpcRequest = new() @@ -1285,6 +1288,7 @@ public ValueTask GetTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(GetTaskAsync)); return SendRequestAsync( RequestMethods.TasksGet, @@ -1307,6 +1311,7 @@ public ValueTask UpdateTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); return SendRequestAsync( RequestMethods.TasksUpdate, @@ -1346,6 +1351,7 @@ public ValueTask CancelTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); return SendRequestAsync( RequestMethods.TasksCancel, @@ -1376,30 +1382,42 @@ public ValueTask CancelTaskAsync( // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - // TODO: replace the literal with a shared NotificationMethods.ClientCapabilitiesMetaKey once - // the SEP-2575 plumbing lands and drop the local consts. - private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; - private const string ExtensionsKey = "extensions"; - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) { JsonObject meta = existingMeta is not null ? (JsonObject)existingMeta.DeepClone() : []; - if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot) + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) { capsRoot = []; - meta[ClientCapabilitiesMetaKey] = capsRoot; + meta[MetaKeys.ClientCapabilities] = capsRoot; } - if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot) + if (capsRoot["extensions"] is not JsonObject extensionsRoot) { extensionsRoot = []; - capsRoot[ExtensionsKey] = extensionsRoot; + capsRoot["extensions"] = extensionsRoot; } extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); return meta; } + + /// + /// Throws when the negotiated protocol version is not the draft revision. The SEP-2663 Tasks + /// extension is draft-only, and a task id only ever exists when the session negotiated draft, so + /// invoking tasks/get, tasks/update, or tasks/cancel on a legacy session is a + /// programming error rather than a recoverable protocol condition. + /// + private void ThrowIfTasksNotSupported(string operationName) + { + if (!IsDraftProtocol()) + { + throw new InvalidOperationException( + $"'{operationName}' requires the draft protocol revision ('{DraftProtocolVersion}'). " + + $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'. " + + "The Tasks extension is only available under the draft revision."); + } + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 894ca6945..eb3ced9a4 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -289,55 +289,145 @@ 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)) + // The draft protocol revision (SEP-2575) is the default: there is no initialize + // handshake. Instead, the client calls server/discover to learn the server's + // capabilities and then begins sending normal RPCs that carry protocolVersion / + // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion + // prefers the draft revision and automatically falls back to the legacy initialize + // handshake when the server doesn't support it. The legacy branch below runs only + // when the caller explicitly pins a non-draft version (opting out of draft). + if (_options.ProtocolVersion is null || _options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion) { - 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 configurable via McpClientOptions.DiscoverProbeTimeout and is + // always bounded by InitializationTimeout (only applied when it is the tighter bound). + var probeTimeout = _options.DiscoverProbeTimeout; + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); + if (_options.InitializationTimeout > probeTimeout) + { + probeCts.CancelAfter(probeTimeout); + } - _serverCapabilities = initializeResponse.Capabilities; - _serverInfo = initializeResponse.ServerInfo; - _serverInstructions = initializeResponse.Instructions; + 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; + } - // Validate protocol version - bool isResponseProtocolValid = - _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); - if (!isResponseProtocolValid) + 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; + } + + 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. Reached only when the caller explicitly pinned a + // non-draft ProtocolVersion (opting out of the draft default), so + // _options.ProtocolVersion is non-null here. + 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 +445,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 +626,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 +665,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,9 +675,13 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + InjectDraftMetaIfNeeded(request); } else { + // An input_required result carrying neither inputRequests nor requestState is + // malformed: there is nothing to resolve and nothing to continue, so retrying the + // unchanged request would just loop until maxRetries. Fail fast instead. throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); } @@ -528,6 +694,32 @@ 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()); + } + /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => _sessionHandler.SendMessageAsync(message, cancellationToken); @@ -563,7 +755,7 @@ public override async ValueTask DisposeAsync() /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. private void WarnIfLegacyRequestOnMrtrSession(string method) { - if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) + if (IsDraftProtocol()) { LogLegacyRequestOnMrtrSession(_endpointName, method); } @@ -572,7 +764,7 @@ private void WarnIfLegacyRequestOnMrtrSession(string method) /// Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult. private void WarnIfInputRequiredResultOnNonMrtrSession(string method) { - if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion) + if (!IsDraftProtocol()) { LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 1e3bdc4bf..4e9a7acce 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,18 +52,52 @@ public sealed class McpClientOptions /// /// /// - /// The protocol version is a key part of the initialization handshake. The client and server must - /// agree on a compatible protocol version to communicate successfully. + /// When non-, this version is requested from the server. Setting it to a + /// legacy (non-draft) version such as 2025-11-25 opts out of the draft revision and forces + /// the initialize handshake; the handshake then fails if the server's negotiated version + /// does not match. /// /// - /// If non-, this version will be sent to the server, and the handshake - /// will fail if the version in the server's response does not match this version. - /// If , the client will request the latest version supported by the server - /// but will allow any supported version that the server advertises in its response. + /// When (the default), the client prefers the draft revision + /// (): it probes with server/discover and + /// automatically falls back to a legacy initialize handshake, downgrading to any version + /// the server advertises, when the server does not support the draft revision. /// /// 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. Because the client already prefers the draft revision by default, 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. + /// + /// + /// + /// // The draft revision is already the default; pin the minimum to refuse the legacy fallback. + /// var clientOptions = new McpClientOptions + /// { + /// MinProtocolVersion = McpSession.DraftProtocolVersion, + /// }; + /// + /// + /// + public string? MinProtocolVersion { get; set; } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// @@ -83,6 +117,52 @@ public sealed class McpClientOptions /// public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60); + /// + /// Gets or sets the timeout applied to the server/discover probe that the client issues + /// before falling back to the legacy initialize handshake. + /// + /// + /// The probe timeout. The default value is 5 seconds. Use + /// to disable the separate probe timeout + /// and rely solely on . + /// + /// + /// + /// This timeout only has an effect when the client prefers the draft protocol revision — that is, + /// when is (the default) or + /// . In that mode the client first probes the server + /// with a server/discover request. A legacy server that predates the draft revision may + /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the + /// client concludes the server is legacy and falls back to the initialize handshake on the + /// same connection. When the caller pins a legacy , no probe is issued + /// and this value has no effect. + /// + /// + /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or + /// satellite links) where a short probe could trigger the legacy fallback before a draft-capable + /// server has had a chance to respond. The probe is always also bounded by + /// , which governs the overall connect budget: if this value is + /// greater than or equal to , the probe is effectively bounded by + /// alone. + /// + /// + /// + /// The value is not positive and is not . + /// + public TimeSpan DiscoverProbeTimeout + { + get; + set + { + if (value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "must be positive or Timeout.InfiniteTimeSpan."); + } + field = value; + } + } = TimeSpan.FromSeconds(5); + /// /// Gets or sets the container of handlers used by the client for processing protocol messages. /// diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 2cebccb3b..c02d5eaea 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -63,9 +63,74 @@ 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 && + await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } 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; + + /// + /// Reads a JSON-RPC error envelope from an application/json response body, returning + /// when the response isn't JSON, is empty, or doesn't parse to a + /// . Shared with the auto-detecting transport so it can tell an MCP + /// server that rejected the request apart from a non-MCP endpoint without throwing. + /// + internal static async Task TryReadJsonRpcErrorAsync(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 + { + // Not a valid JSON-RPC error response — fall through to the standard HTTP exception path. + return null; + } + } + // 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 +144,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 +161,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 +227,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[MetaKeys.ProtocolVersion] 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 414d0dafc..32a68104b 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -134,8 +134,14 @@ internal static bool IsValidToolOutputSchema(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))] @@ -201,6 +207,11 @@ internal static bool IsValidToolOutputSchema(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..4804e1ad3 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,6 +28,27 @@ 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. + /// + /// The draft revision removes the initialize handshake (SEP-2575) and the + /// Mcp-Session-Id header (SEP-2567), so it is sessionless on the wire and over HTTP is only + /// served when the server is stateless. A stateful (HttpServerTransportOptions.Stateless = false) + /// server refuses a sessionless draft request so that a dual-era client downgrades to the legacy + /// initialize flow. Clients prefer this revision by default and automatically fall back to the + /// legacy flow when the server does not support it; pin + /// to a legacy version to opt out, or set to this + /// value to keep the draft preference while refusing the legacy 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. @@ -45,6 +66,17 @@ public abstract partial class McpSession : IAsyncDisposable /// public abstract string? NegotiatedProtocolVersion { get; } + /// + /// Gets a value indicating whether the negotiated protocol version is the draft revision + /// (, which carries SEP-2575 + SEP-2567 + MRTR). + /// + /// + /// Returns when no version has been negotiated yet. This is the shared + /// definition of "is this peer speaking the draft revision" used by both the client and server. + /// + internal bool IsDraftProtocol() => + NegotiatedProtocolVersion == DraftProtocolVersion; + /// /// Sends a JSON-RPC request to the connected session and waits for a response. /// diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 00f2231d6..1a33fe670 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -32,11 +32,15 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable internal const string LatestProtocolVersion = "2025-11-25"; /// - /// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322. - /// Clients and servers opt in by setting - /// or to this value. + /// The draft protocol version (SEP-2575 + SEP-2567) that removes the initialize handshake + /// and Mcp-Session-Id and enables MRTR (Multi Round-Trip Requests) per SEP-2322. + /// Clients prefer this revision by default and fall back to the legacy initialize handshake + /// when the server does not support it; pin to a + /// legacy version to opt out. Servers remain reactive: with + /// left they honor whichever + /// supported revision each peer requests, so a single server serves both draft and legacy clients. /// - internal const string DraftProtocolVersion = "DRAFT-2026-v1"; + internal const string DraftProtocolVersion = "2026-07-28"; /// /// All protocol versions supported by this implementation. @@ -165,10 +169,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); @@ -287,6 +306,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, @@ -395,6 +426,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); @@ -530,6 +569,127 @@ 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[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && + protocolVersion.TryGetValue(out string? protocolVersionValue)) + { + // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already + // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with + // -32001 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), + // which conformant draft clients recognize as a modern-server signal and surface as-is rather + // than mistaking it for a legacy server and falling back to the initialize handshake. + if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", + McpErrorCode.HeaderMismatch); + } + + context.ProtocolVersion = protocolVersionValue; + } + + if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) + { + context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); + } + + if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) + { + context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + } + + if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) + { + context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + } + + /// + /// Injects the draft-protocol per-request _meta fields into an outgoing request. + /// Protocol version and client info overwrite any existing values; client capabilities are merged + /// so per-request capability opt-ins already present in the envelope are preserved. + /// + /// + /// 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[MetaKeys.ProtocolVersion] = protocolVersion; + metaObj[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation); + + // Overlay the session-level standard capabilities onto whatever the request already carried + // in _meta.clientCapabilities. A caller higher up the pipeline (e.g. CallToolRawAsync via + // GetMetaWithTaskCapability) may have already written per-request capability opt-ins such as + // extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those + // additions, so merge instead: set the standard capability fields from the session + // capabilities while preserving any extra keys (extensions) the request envelope already had. + var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!; + if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities) + { + foreach (var property in serializedCapabilities.ToArray()) + { + existingCapabilities[property.Key] = property.Value?.DeepClone(); + } + } + else + { + metaObj[MetaKeys.ClientCapabilities] = serializedCapabilities; + } + + if (logLevel is { } level) + { + metaObj[MetaKeys.LogLevel] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + else + { + metaObj.Remove(MetaKeys.LogLevel); + } + } + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) @@ -1018,6 +1178,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; @@ -1028,6 +1199,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/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 index ecf39e976..93797df05 100644 --- a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -7,8 +7,9 @@ namespace ModelContextProtocol.Protocol; /// /// /// This interface corresponds to the CacheableResult type in the Model Context Protocol -/// schema and is implemented by the results of tools/list, prompts/list, -/// resources/list, resources/templates/list, and resources/read. +/// 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 diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 1dfef5de1..646dac75e 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -83,6 +83,7 @@ public sealed class Converter : JsonConverter // Local variables for parsed message data bool hasJsonRpc = false; RequestId id = default; + bool hasId = false; string? method = null; JsonNode? parameters = null; JsonRpcErrorDetail? error = null; @@ -118,6 +119,7 @@ public sealed class Converter : JsonConverter case "id": id = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + hasId = true; break; case "method": @@ -153,6 +155,16 @@ public sealed class Converter : JsonConverter // Determine message type based on presence of id and method properties if (method is not null) { + if (hasId && id.Id is null) + { + // A request that carries an explicit `id: null` is malformed. The MCP base protocol + // states "Unlike base JSON-RPC, the ID MUST NOT be null", and a null id does NOT denote + // a notification — per JSON-RPC 2.0 a Notification is a Request object *without* an id + // member. Reject it rather than silently downgrading to a notification (which would + // drop the malformed id and skip sending any response). + throw new JsonException("Request id must not be null. Per MCP, a request id must be a non-null string or number; omit the id member entirely to send a notification."); + } + if (id.Id is not null) { // Messages with both method and id are requests @@ -165,7 +177,7 @@ public sealed class Converter : JsonConverter } else { - // Messages with a method but no id are notifications + // Messages with a method but no id member are notifications return new JsonRpcNotification { Method = method, @@ -200,6 +212,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/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs new file mode 100644 index 000000000..495f71553 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -0,0 +1,76 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Provides constants for well-known _meta field keys defined by the MCP protocol and its extensions. +/// +public static class MetaKeys +{ + /// + /// 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 ProtocolVersion = "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 ClientInfo = "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 ClientCapabilities = "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 LogLevel = "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 SubscriptionId = "io.modelcontextprotocol/subscriptionId"; + + /// + /// 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 RelatedTask = "io.modelcontextprotocol/related-task"; +} 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 825abd92b..39fe0780f 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -152,5 +152,15 @@ public static class NotificationMethods /// Each notification carries a complete task state for the current status, identical to what /// tasks/get would have returned at that moment. /// - public const string TaskStatusNotification = "notifications/tasks"; + public const string TaskStatusNotification = "notifications/tasks/status"; + + /// + /// 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"; } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 47a6fde61..692d9b7b9 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"), }; } @@ -86,7 +87,11 @@ public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerialize return; case null: - writer.WriteStringValue(string.Empty); + // A null Id represents a JSON-RPC error response whose request id could not be + // determined (JSON-RPC 2.0 §5; the MCP base protocol permits an error response to a + // malformed request to carry a null id). Emit JSON null — not "" — so the wire form + // is spec-conformant and round-trips losslessly with the Null-accepting Read above. + writer.WriteNullValue(); return; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a6f22148e..028a5629d 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -151,4 +151,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/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/ISseEventStreamReader.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs index 01c642355..dc3a7426c 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs @@ -6,6 +6,7 @@ namespace ModelContextProtocol.Server; /// /// Provides read access to an SSE event stream, allowing events to be consumed asynchronously. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamReader { /// diff --git a/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs index 3d9d9b948..20dda4a18 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Provides storage and retrieval of SSE event streams, enabling resumability and redelivery of events. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamStore { /// diff --git a/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs index 43ddb2361..20d9c747c 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs @@ -6,6 +6,7 @@ namespace ModelContextProtocol.Server; /// /// Provides write access to an SSE event stream, allowing events to be written and tracked with unique IDs. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamWriter : IAsyncDisposable { /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 0a0721c7c..2cc5d4621 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(); @@ -102,20 +104,10 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _notificationHandlers.RegisterRange(notificationHandlers); } - // In stateless mode, the server cannot send unsolicited notifications, - // so listChanged should not be advertised. - if (transport is StreamableHttpServerTransport { Stateless: true }) - { - if (ServerCapabilities.Tools is not null) - ServerCapabilities.Tools.ListChanged = null; - if (ServerCapabilities.Prompts is not null) - ServerCapabilities.Prompts.ListChanged = null; - if (ServerCapabilities.Resources is not null) - ServerCapabilities.Resources.ListChanged = null; - } - - // Now that everything has been configured, subscribe to any necessary notifications. - if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false) + // A stateful session can push unsolicited list-changed notifications, so subscribe to the + // collection change events. A stateless HTTP server cannot send unsolicited notifications, so + // instead suppress the listChanged capability it would otherwise advertise. + if (IsStatefulSession()) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); @@ -126,16 +118,27 @@ void Register(McpServerPrimitiveCollection? collection, { if (collection is not null) { - EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(notificationMethod); + EventHandler changed = (sender, e) => _ = SendListChangedNotificationAsync(notificationMethod); collection.Changed += changed; _disposables.Add(() => collection.Changed -= changed); } } } + else + { + if (ServerCapabilities.Tools is not null) + ServerCapabilities.Tools.ListChanged = null; + if (ServerCapabilities.Prompts is not null) + ServerCapabilities.Prompts.ListChanged = null; + if (ServerCapabilities.Resources is not null) + ServerCapabilities.Resources.ListChanged = null; + } - // And initialize the session. - var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters); + // And initialize the session. The built-in draft state-sync filter runs ahead of any + // user-supplied incoming filters; see PrependDraftStateSyncFilter for what it records and why. + var incomingMessageFilter = PrependDraftStateSyncFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters)); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); + _sessionHandler = new McpSessionHandler( isServer: true, _sessionTransport, @@ -147,12 +150,106 @@ void Register(McpServerPrimitiveCollection? collection, _logger); } + /// + /// Wraps so that, for every JSON-RPC request, a built-in filter first + /// synchronizes server-side state (, + /// , ) from the per-request _meta + /// values projected onto and validates the per-request protocol + /// version, before delegating to the user-supplied incoming filters. + /// + /// + /// 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 the built-in + /// filter is a no-op (the values were captured during the initialize handler). + /// + private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter inner) + { + JsonRpcMessageFilter draftStateSync = 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); + } + + SetNegotiatedProtocolVersion(protocolVersion); + } + + if (context.ClientCapabilities is { } clientCapabilities && IsDraftProtocol() && IsStatefulSession()) + { + // Under the draft revision the per-request _meta envelope carries the client's FULL + // capabilities (SEP-2575), so a plain overwrite is correct. The IsDraftProtocol() gate + // makes any legacy per-request envelope a no-op (legacy capabilities stay as the + // initialize handshake established them); the IsStatefulSession() gate keeps + // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } + // (where the same server instance handles every request, so persisting per-request + // capability state would both leak across requests and break the StatelessServerTests + // invariant that surfaces the "X is not supported in stateless mode" errors). + _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); + }; + + return next => draftStateSync(inner(next)); + } + /// public override string? SessionId => _sessionTransport.SessionId; /// public override string? NegotiatedProtocolVersion => _negotiatedProtocolVersion; + /// + /// Records the negotiated MCP protocol version for the session. The version is established exactly + /// once: the initial -to-value transition is allowed (and racing requests that + /// select the same version are idempotent no-ops), but any later attempt to switch to a different + /// version throws. A single session MUST NOT change protocol versions, so a conflicting per-request + /// _meta protocol version (or Mcp-Protocol-Version header) is a client error rather than + /// something we silently overwrite. + /// + private void SetNegotiatedProtocolVersion(string protocolVersion) + { + string? previous = Interlocked.CompareExchange(ref _negotiatedProtocolVersion, protocolVersion, null); + if (previous is null) + { + // We won the initial null-to-value transition; publish it to the session handler for telemetry. + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + } + else if (!string.Equals(previous, protocolVersion, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"The negotiated protocol version cannot change within a session. " + + $"The session negotiated '{previous}', but a request specified '{protocolVersion}'.", + McpErrorCode.InvalidRequest); + } + } + /// public ServerCapabilities ServerCapabilities { get; } @@ -273,9 +370,12 @@ private void ConfigureInitialize(McpServerOptions options) clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; + // The legacy initialize handshake is authoritative: it may supersede a protocol version + // a prior draft server/discover probe established on the same connection (the dual-era + // fallback path a permissive client takes against an unknown server). Unlike the + // per-request draft version - which SetNegotiatedProtocolVersion locks once negotiated - + // initialize force-sets the version. _negotiatedProtocolVersion = protocolVersion; - - // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = protocolVersion; return new InitializeResult @@ -290,6 +390,228 @@ 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) => + { + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); + + // A stateless session (Streamable HTTP with no session) cannot deliver out-of-band + // notifications: each request is isolated and nothing outlives it to push later list/resource + // changes back to the client (tracked by #1662). Rather than hold the POST open forever only + // to deliver nothing - pinning the connection and its request scope - acknowledge the listen + // request granting no notifications and complete immediately. This runs after protocol + // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the + // initialize handshake. + if (!IsStatefulSession()) + { + var statelessSubscription = new ActiveSubscription( + jsonRpcRequest.Id, + new SubscriptionsListenNotifications(), + jsonRpcRequest.Context?.RelatedTransport); + + await SendSubscriptionAckAsync(statelessSubscription, cancellationToken).ConfigureAwait(false); + + return new EmptyResult(); + } + + // Filter the requested notifications against what the server actually supports. + 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 list-changed notifications can be fanned out to it, tagged with + // the right subscriptionId, and routed back over the stream this request opened. + var subscription = new ActiveSubscription( + jsonRpcRequest.Id, + granted, + jsonRpcRequest.Context?.RelatedTransport); + _activeSubscriptions[jsonRpcRequest.Id] = subscription; + + try + { + // Send the acknowledgement notification first, as required by SEP-2575. Like every other + // notification delivered on the subscription it is routed back over this request's own + // stream and tagged with the subscription id so shared-channel clients can demultiplex it. + await SendSubscriptionAckAsync(subscription, 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. + /// The id of the subscriptions/listen request, reused as the SEP-2575 subscription id. + /// The notification types the server agreed to deliver on this subscription. + /// + /// The transport the subscriptions/listen request arrived on. For Streamable HTTP this is the + /// per-request response stream the subscription must be delivered on; for stdio it is , + /// so notifications fall back to the shared session channel. + /// + private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifications Granted, ITransport? RelatedTransport); + + private readonly ConcurrentDictionary _activeSubscriptions = new(); + + /// + /// Delivers a */list_changed notification triggered by a server-side collection change. + /// + /// + /// Pre-SEP-2575 clients do not open subscriptions/listen streams, so they keep receiving a single + /// session-wide broadcast. Draft clients instead receive only the change notifications they explicitly + /// requested, each routed back over the originating subscription stream and tagged with its id; the server + /// MUST NOT send a draft client notification types it never subscribed to. + /// + private async Task SendListChangedNotificationAsync(string notificationMethod) + { + // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. + // subscriptions/listen is a SEP-2575 draft feature, so draft clients instead get a fan-out limited + // to the notification types they explicitly subscribed to. + if (!IsDraftProtocol()) + { + await this.SendNotificationAsync(notificationMethod).ConfigureAwait(false); + return; + } + + foreach (var subscription in _activeSubscriptions.Values) + { + if (!GrantsListChanged(subscription.Granted, notificationMethod)) + { + continue; + } + + try + { + await SendSubscriptionNotificationAsync(subscription, notificationMethod, paramsNode: null, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // A single closed or faulted subscription stream must not prevent fan-out to the others. + SubscriptionNotificationFailed(notificationMethod, subscription.Id.ToString(), ex); + } + } + } + + /// + /// Sends over 's stream, tagging it with the + /// SEP-2575 _meta subscription id so clients sharing a channel (notably stdio) can demultiplex it. + /// + private Task SendSubscriptionNotificationAsync(ActiveSubscription subscription, string method, JsonNode? paramsNode, CancellationToken cancellationToken) + { + var paramsObject = paramsNode as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscription.Id.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + var notification = new JsonRpcNotification + { + Method = method, + Params = paramsObject, + Context = new JsonRpcMessageContext { RelatedTransport = subscription.RelatedTransport }, + }; + + return SendMessageAsync(notification, cancellationToken); + } + + /// + /// Sends the SEP-2575 subscriptions/acknowledged notification for a subscription, carrying the + /// notification types the server agreed to deliver. Routed back over the subscription's own stream and + /// tagged with its id like every other subscription notification. + /// + private Task SendSubscriptionAckAsync(ActiveSubscription subscription, CancellationToken cancellationToken) + { + var ackParams = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams { Notifications = subscription.Granted }, + McpJsonUtilities.JsonContext.Default.SubscriptionsAcknowledgedNotificationParams); + + return SendSubscriptionNotificationAsync( + subscription, + NotificationMethods.SubscriptionsAcknowledgedNotification, + ackParams, + cancellationToken); + } + + /// Maps a */list_changed method to the subscription filter flag that enables it. + private static bool GrantsListChanged(SubscriptionsListenNotifications granted, string method) => method switch + { + NotificationMethods.ToolListChangedNotification => granted.ToolsListChanged == true, + NotificationMethods.PromptListChangedNotification => granted.PromptsListChanged == true, + NotificationMethods.ResourceListChangedNotification => granted.ResourcesListChanged == true, + _ => false, + }; + private void ConfigureCompletion(McpServerOptions options) { var completeHandler = options.Handlers.CompleteHandler; @@ -487,6 +809,13 @@ private void ConfigureTasks(McpServerOptions options) updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); + // The tasks/* methods do not exist before the draft revision (SEP-2663). Reject them with + // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers + // stay registered so a dual-era server still serves them for draft requests. + getTaskHandler = GateTaskMethodToDraft(getTaskHandler, RequestMethods.TasksGet); + updateTaskHandler = GateTaskMethodToDraft(updateTaskHandler, RequestMethods.TasksUpdate); + cancelTaskHandler = GateTaskMethodToDraft(cancelTaskHandler, RequestMethods.TasksCancel); + // Advertise tasks extension in server capabilities. ServerCapabilities.Extensions ??= new Dictionary(); ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); @@ -510,6 +839,26 @@ private void ConfigureTasks(McpServerOptions options) McpJsonUtilities.JsonContext.Default.CancelTaskResult); } + /// + /// Wraps a tasks/* request handler so it throws unless the + /// request was negotiated under the draft revision. The tasks extension (SEP-2663) only interoperates + /// under draft, and these methods don't exist on legacy peers. + /// + private McpRequestHandler GateTaskMethodToDraft( + McpRequestHandler inner, string method) + => (request, cancellationToken) => + { + if (!IsDraftProtocolRequest(request.JsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{method}' requires the draft protocol revision ('{DraftProtocolVersion}'); " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + + return inner(request, cancellationToken); + }; + private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; @@ -911,7 +1260,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) var innerTaskHandler = callToolWithTaskHandler; callToolWithTaskHandler = async (request, cancellationToken) => { - if (HasTaskExtensionOptIn(request.Params?.Meta)) + // The SEP-2663 Tasks extension is draft-only: the task wire shapes we ship do not + // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the + // request was negotiated under the draft revision AND the client opted in; otherwise + // run the inner handler and return the direct result (best-effort downgrade, which also + // defends against a non-conformant legacy client that forges the opt-in envelope). + if (IsDraftProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) { var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); var taskId = taskInfo.TaskId; @@ -1353,15 +1707,10 @@ private static McpRequestHandler BuildFilterPipeline meta is not null && - meta[ClientCapabilitiesMetaKey] is JsonObject caps && - caps[ExtensionsKey] is JsonObject exts && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && exts.ContainsKey(McpExtensions.Tasks); private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) @@ -1421,10 +1770,12 @@ 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). MRTR rides on + /// the draft revision, so this is the MRTR-meaning alias of - + /// use it at the input-required/handler-suspension sites where the intent is "the client understands + /// " rather than "the peer speaks the draft revision". /// - internal bool ClientSupportsMrtr() => - _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; + internal bool ClientSupportsMrtr() => IsDraftProtocol(); /// /// Returns when the session is stateful - the same server instance handles @@ -1436,6 +1787,18 @@ internal bool ClientSupportsMrtr() => internal bool IsStatefulSession() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + /// + /// Returns when the given request was negotiated under the draft protocol + /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works + /// for sessionless draft over stateless HTTP) and falling back to the session-negotiated version. + /// Used to gate the SEP-2663 Tasks extension, which only interoperates under the draft revision. + /// + private bool IsDraftProtocolRequest(JsonRpcRequest? request) => + string.Equals( + request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion, + DraftProtocolVersion, + StringComparison.Ordinal); + /// public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); @@ -1453,14 +1816,6 @@ internal bool IsStatefulSession() => { const int MaxRetries = 10; - // In stateless mode, pick up the negotiated draft protocol version from the - // transport-provided request context because there is no long-lived initialize handshake state. - if (_negotiatedProtocolVersion is null && - request.Context?.ProtocolVersion is { } headerProtocolVersion) - { - _negotiatedProtocolVersion = headerProtocolVersion; - } - for (int retry = 0; ; retry++) { try @@ -1479,7 +1834,6 @@ 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. if (!IsStatefulSession()) { throw new McpException( @@ -1646,15 +2000,6 @@ private void WrapHandlerWithMrtr(string method) _requestHandlers[method] = async (request, cancellationToken) => { - // In stateless mode, each request creates a new server instance that never saw the - // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the - // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. - if (_negotiatedProtocolVersion is null && - request.Context?.ProtocolVersion is { } headerProtocolVersion) - { - _negotiatedProtocolVersion = headerProtocolVersion; - } - // Check for MRTR retry: if requestState is present, look up the continuation. if (request.Params is JsonObject paramsObj && paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && @@ -1706,7 +2051,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 @@ -1873,4 +2218,7 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")] + private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs b/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs index 2b7704d3d..3439acc60 100644 --- a/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs +++ b/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Represents the mode of an SSE event stream. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public enum SseEventStreamMode { /// diff --git a/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs b/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs index 6d5be24ef..7eea0973c 100644 --- a/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs +++ b/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Configuration options for creating an SSE event stream. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public sealed class SseEventStreamOptions { /// diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 568afd223..ed8c3293a 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -22,7 +22,9 @@ internal sealed partial class StreamableHttpPostTransport( private readonly SseEventWriter _httpSseWriter = new(responseStream); private TaskCompletionSource? _storeStreamTcs; +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private ISseEventStreamWriter? _storeSseWriter; +#pragma warning restore MCP9006 private RequestId _pendingRequest; private bool _finalResponseMessageSent; @@ -176,7 +178,9 @@ public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationTo // Set the mode to 'Polling' so that the replay stream ends as soon as all available messages have been sent. // This prevents the client from immediately establishing another long-lived connection. +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. await _storeSseWriter.SetModeAsync(SseEventStreamMode.Polling, cancellationToken).ConfigureAwait(false); +#pragma warning restore MCP9006 // Signal completion so HandlePostAsync can return. _httpResponseTcs.TrySetResult(true); diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 87d353426..131c836dc 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -40,7 +40,9 @@ public sealed partial class StreamableHttpServerTransport : ITransport private readonly ILogger _logger; private SseEventWriter? _httpSseWriter; +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private ISseEventStreamWriter? _storeSseWriter; +#pragma warning restore MCP9006 private TaskCompletionSource? _httpResponseTcs; private string? _negotiatedProtocolVersion; private bool _getHttpRequestStarted; @@ -80,6 +82,7 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null) /// Gets or sets the event store for resumability support. /// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header. /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISseEventStreamStore? EventStreamStore { get; init; } /// @@ -357,6 +360,7 @@ public async ValueTask DisposeAsync() } } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. internal async ValueTask TryCreateEventStreamAsync(string streamId, CancellationToken cancellationToken) { if (EventStreamStore is null || !McpSessionHandler.SupportsPrimingEvent(_negotiatedProtocolVersion)) @@ -377,6 +381,7 @@ public async ValueTask DisposeAsync() return sseEventStreamWriter; } +#pragma warning restore MCP9006 [LoggerMessage(Level = LogLevel.Warning, Message = "Sending server-to-client JSON-RPC request '{Method}' over the standalone GET SSE stream (SessionId: '{SessionId}'). " + 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/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 0d717ef10..36c6a1736 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0;netstandard2.0 @@ -10,6 +10,11 @@ True $(NoWarn);MCPEXP001 + + $(NoWarn);MCP9006 + + $(NoWarn);CS0436 diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs index f434e12c3..227bf132e 100644 --- a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs +++ b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs @@ -5,6 +5,7 @@ namespace ModelContextProtocol.Server; /// /// Configuration options for . /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public sealed class DistributedCacheEventStreamStoreOptions { /// diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 374184cfa..b549bdd76 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Utils; @@ -187,8 +188,12 @@ public static bool IsNodeInstalled() /// 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)); + public static bool HasSep2243Scenarios() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -197,8 +202,47 @@ public static bool IsNodeInstalled() /// 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)); + 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 distPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + if (!File.Exists(distPath)) + { + return false; + } + + var bundled = File.ReadAllText(distPath); + return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + } + catch + { + return false; + } + } /// /// Returns when the conformance package installed in node_modules @@ -373,42 +417,20 @@ private static bool ConformanceOutputIndicatesSuccess(string output) } /// - /// 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 + /// 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() - { - try - { - var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); - if (!File.Exists(packageJsonPath)) - { - 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-2322 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); - } - } - - return false; - } - catch - { - return false; - } - } + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 852fb122e..9e2040d1b 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 = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) @@ -146,7 +146,7 @@ public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToo TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register the tool WITHOUT calling ListToolsAsync first — this is the core scenario from issue #1577 @@ -186,7 +186,7 @@ public async Task CallTool_EmitsCanonicalIntegerHeader(string bodyValue, string TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); client.AddKnownTools([CreateToolWithHeaders()]); @@ -222,7 +222,7 @@ public async Task CallTool_ThrowsForInvalidIntegerHeaderValue(string bodyValue) TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); client.AddKnownTools([CreateToolWithHeaders()]); @@ -252,7 +252,7 @@ public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Call the tool without AddKnownTools or ListToolsAsync — no Mcp-Param-* headers should be sent @@ -285,7 +285,7 @@ public async Task AddKnownTools_SurvivesListToolsAsync_HeadersStillSent() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register the tool first @@ -322,7 +322,7 @@ public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register then remove — headers should no longer be sent @@ -376,7 +376,7 @@ public async Task AddKnownTools_ServerReturnsEmptyList_RegisteredToolStillUsedFo TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register tool, then ListToolsAsync returns empty list from server @@ -411,7 +411,7 @@ public async Task AddKnownTools_ReRegisterOverwrite_LastWriteWinsHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register with header "SchemaA", then overwrite with "SchemaB" diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 5cdd2948a..27cba0d40 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -105,12 +105,14 @@ public async ValueTask DisposeAsync() /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle. -/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when -/// running against the currently-pinned package, so it activates automatically once a -/// conformance package containing the caching scenario is installed (including a local private -/// build installed 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. +/// 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) { @@ -128,7 +130,7 @@ public async Task RunCachingConformanceTest() // 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 DRAFT-2026-v1", + $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", line => { try { output.WriteLine(line); } catch { } }, appendProtocolVersionFromEnv: false, cancellationToken: TestContext.Current.CancellationToken); 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..b6f3352ae --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -0,0 +1,200 @@ +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(bool stateless = false) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; + }).WithHttpTransport(options => + { + // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions, which the + // draft revision cannot honor (so sessionless draft requests are refused). Stateless = true (the + // default) serves sessionless draft natively. + options.Stateless = stateless; + }); + + _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_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() + { + await StartAsync(stateless: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + // On a stateless server, sessionless draft server/discover succeeds 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 DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() + { + // The draft revision is sessionless (SEP-2567), so it cannot honor a server configured with + // sessions (Stateless = false). The server refuses the draft version with + // UnsupportedProtocolVersion (excluding draft from Supported) so a dual-era client falls back + // to the legacy initialize handshake. + await StartAsync(stateless: false); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + 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); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); + + 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); + + var dataElement = (JsonElement)rpcError.Error.Data!; + var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); + Assert.NotNull(errorData); + Assert.Equal(DraftVersion, errorData.Requested); + // The draft version is excluded from Supported so the client downgrades to a legacy version. + Assert.NotEmpty(errorData.Supported); + Assert.DoesNotContain(DraftVersion, errorData.Supported); + } + + [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_IsRejected() + { + // The draft revision is sessionless (SEP-2567): a draft request carrying an Mcp-Session-Id is + // non-conformant and is rejected with 400 regardless of the Stateless setting. + 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); + + Assert.Equal(HttpStatusCode.BadRequest, 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 DraftGet_WithSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + 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); + } + + [Fact] + public async Task DraftDelete_WithSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs new file mode 100644 index 000000000..18815fd00 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// End-to-end coverage for a default (draft-first) client connecting to a real C# Streamable HTTP +/// server that deliberately opted into sessions ( +/// is false). Draft is sessionless (SEP-2567 / SEP-2575), so the server refuses the +/// sessionless draft probe with -32004 UnsupportedProtocolVersion. The client must then +/// auto-downgrade to the legacy initialize handshake, obtain the stateful session the server +/// author opted into, and continue to work — including a server→client elicitation round-trip +/// resolved over the stateful session via the legacy backcompat resolver. +/// +public class DraftStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [McpServerTool(Name = "greet")] + private static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "greet_via_elicit")] + private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) + { + // Server→client round-trip: only works when the session is stateful, which is exactly what + // the legacy fallback re-establishes for the draft-first client. + var elicitResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new(), + }, cancellationToken); + + var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true + ? answer.GetString() + : "stranger"; + + return $"Hello, {name}!"; + } + + private async Task StartStatefulServerAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftStatefulFallbackTests), Version = "1" }; + }) + // Stateless = false is a deliberate opt-in to sessions. Draft can never be served + // statefully, so the server refuses the sessionless draft probe and the client downgrades. + .WithHttpTransport(options => options.Stateless = false) + .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private async Task ConnectDefaultClientAsync(Action? configureClient = null) + { + await using var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + // Default options: ProtocolVersion is null, which now prefers the draft revision and probes + // with server/discover before falling back to a legacy initialize handshake. + var clientOptions = new McpClientOptions(); + configureClient?.Invoke(clientOptions); + return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + { + await StartStatefulServerAsync(); + + await using var client = await ConnectDefaultClientAsync(); + + // The sessionless draft probe was refused (-32004), so the client downgraded to legacy. + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("greet", + new Dictionary { ["name"] = "Alice" }, + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("Hello, Alice!", text); + } + + [Fact] + public async Task DefaultDraftClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips() + { + await StartStatefulServerAsync(); + + await using var client = await ConnectDefaultClientAsync(options => + { + options.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(), + }, + }); + }); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("greet_via_elicit", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("Hello, Bob!", text); + Assert.True(result.IsError is not true); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index b950553f5..db751af6b 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,8 +615,9 @@ 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..6f36d1421 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -211,7 +211,7 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() { Builder.Services .AddMcpServer() - .WithHttpTransport(); + .WithHttpTransport(options => options.Stateless = false); using var app = Builder.Build(); @@ -220,7 +220,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. + // In stateful mode 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/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 5f961fe32..5fd4d49e4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -34,7 +34,9 @@ public async Task ConnectAndPing_Sse_TestServer() // Arrange // Act - await using var client = await GetClientAsync(); + // ping was removed in the draft revision (SEP-2575), so pin to the latest stable protocol + // version to keep exercising the legacy ping RPC. Draft liveness relies on the transport. + await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert @@ -47,7 +49,9 @@ public async Task Connect_TestServer_ShouldProvideServerFields() // Arrange // Act - await using var client = await GetClientAsync(); + // Stateful Streamable HTTP only provisions a session ID under the legacy handshake; the draft + // revision is sessionless. Pin to the latest stable version to keep covering session-ID provisioning. + await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); // Assert Assert.NotNull(client.ServerCapabilities); 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/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 3d532802b..dcd1bcf50 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -410,7 +410,7 @@ public async Task CanResumeSessionWithMapMcpAndRunSessionHandler() OwnsSession = false, }, HttpClient, LoggerFactory); - await using (var initialClient = await McpClient.CreateAsync(initialTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (var initialClient = await McpClient.CreateAsync(initialTransport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { resumedSessionId = initialClient.SessionId ?? throw new InvalidOperationException("SessionId not negotiated."); serverCapabilities = initialClient.ServerCapabilities; @@ -486,7 +486,9 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEvent await app.StartAsync(TestContext.Current.CancellationToken); - await using var mcpClient = await ConnectAsync(); + // Polling via an event-stream store is a stateful-session feature. Under draft, Streamable HTTP + // is sessionless, so pin to the latest stable version to keep exercising the stateful path. + await using var mcpClient = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); @@ -538,7 +540,9 @@ public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests() }, }; - await using var mcpClient = await ConnectAsync(transportOptions: transportOptions); + // DELETE requests are only sent when there's a session ID to delete - a legacy stateful + // behavior. Under draft, Streamable HTTP is sessionless. Pin to the latest stable version. + await using var mcpClient = await ConnectAsync(transportOptions: transportOptions, configureClient: options => options.ProtocolVersion = "2025-11-25"); // Do a tool call to ensure there's more than just the initialize request await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -589,7 +593,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse() OwnsSession = false, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Call a tool to ensure the session is fully established var result = await client.CallToolAsync( @@ -657,7 +661,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithUnsolicite OwnsSession = false, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var result = await client.CallToolAsync( "echo_claims_principal", @@ -717,7 +721,9 @@ public async Task Client_CanReconnect_AfterSessionExpiry() await app.StartAsync(TestContext.Current.CancellationToken); // Connect the first client and verify it works. - var client1 = await ConnectAsync(); + // Server-side session expiry and reconnect rely on session IDs, a legacy stateful behavior. + // Under draft, Streamable HTTP is sessionless. Pin both clients to the latest stable version. + var client1 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); var originalSessionId = client1.SessionId; Assert.NotNull(originalSessionId); @@ -739,7 +745,7 @@ await Assert.ThrowsAnyAsync(async () => await client1.DisposeAsync(); // Reconnect with a brand-new session. - await using var client2 = await ConnectAsync(); + await using var client2 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); Assert.NotNull(client2.SessionId); Assert.NotEqual(originalSessionId, client2.SessionId); @@ -753,6 +759,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() { var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>(); var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>(); + var requestObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -782,18 +789,31 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method)); + requestObserved.TrySetResult(); return result; }); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectAsync(); + // The stateful (else) branch below asserts session-ID behavior, which only exists under the + // legacy handshake; the draft revision is sessionless. Pin legacy only for the stateful variant. + await using var client = await ConnectAsync(configureClient: options => + { + if (!Stateless) + { + options.ProtocolVersion = "2025-11-25"; + } + }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // The filter must have observed at least one MCP request. Don't assert an exact - // minimum - the initialized notification or GET stream may not have completed yet. + // The filter records into the bag *after* await next(context) returns. For a streamed SSE + // response the client can observe completion (and ListToolsAsync can return) before that + // server-side continuation runs, so asserting the bag immediately races. Wait for the filter + // to record at least one request first. Don't assert an exact minimum - the initialized + // notification or GET stream may not have completed yet. + await requestObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.NotEmpty(capturedSessionIds); if (Stateless) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index ddae6c66b..e5c5a123f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -10,6 +10,13 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { + // Draft is sessionless (SEP-2567): the Streamable HTTP handler refuses a sessionless draft request + // when the server opted into sessions (Stateless = false), so a draft-pinned client downgrades to + // legacy instead of negotiating 2026-07-28. These draft MRTR tests therefore can't run on the + // stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures. + private const string DraftStatefulStreamableHttpSkipReason = + "Draft is sessionless (SEP-2567); stateful Streamable HTTP refuses sessionless draft. Covered by the stateless and SSE fixtures."; + private ServerMessageTracker ConfigureServer(params Delegate[] tools) { var messageTracker = new ServerMessageTracker(); @@ -17,7 +24,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,11 +37,17 @@ private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); - private Task ConnectDefaultAsync() => - ConnectAsync(configureClient: ConfigureMrtrHandlers); + // The default client now negotiates draft (2026-07-28). The legacy JSON-RPC MRTR back-compat + // resolver only applies to legacy clients, so pin these to the latest non-draft version. + private Task ConnectLegacyAsync() => + ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; + }); /// Configures elicitation, sampling, and roots handlers on client options. private static void ConfigureMrtrHandlers(McpClientOptions options) @@ -79,7 +92,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 +169,15 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. // In stateless mode, those calls succeed only when the request is still open on the same @@ -180,6 +201,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 +224,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 +289,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,8 +300,9 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); @@ -283,7 +310,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); @@ -330,6 +357,8 @@ private static string MrtrElicit(RequestContext context) [Fact] public async Task Mrtr_Roots_CompletesViaMrtr() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => { @@ -351,7 +380,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); @@ -406,6 +435,8 @@ private static string MrtrMulti(RequestContext context) [InlineData(false)] public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) { + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer(MrtrMulti); await using var app = Builder.Build(); app.MapMcp(); @@ -413,8 +444,9 @@ 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"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); if (!experimentalClient && Stateless) @@ -437,7 +469,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 @@ -452,6 +484,8 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) [InlineData(false)] public async Task Mrtr_IsMrtrSupported(bool experimentalClient) { + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); await using var app = Builder.Build(); app.MapMcp(); @@ -459,10 +493,11 @@ 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"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; 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); @@ -515,6 +550,8 @@ private static string MrtrConcurrentThree(RequestContext [Fact] public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer(MrtrConcurrentThree); await using var app = Builder.Build(); app.MapMcp(); @@ -526,7 +563,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 +590,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); @@ -567,6 +604,8 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() [Fact] public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => { @@ -582,7 +621,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); @@ -617,7 +656,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots-backcompat", @@ -668,7 +707,7 @@ public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-multi-input", @@ -707,6 +746,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; var originalHandler = options.Handlers.ElicitationHandler!; options.Handlers.ElicitationHandler = (request, ct) => { @@ -739,7 +779,7 @@ public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var ex = await Assert.ThrowsAsync(() => @@ -762,6 +802,7 @@ public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; options.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index b9b8381ca..b269c9951 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -111,7 +111,10 @@ public async Task Messages_FromNewUser_AreRejected() await app.StartAsync(TestContext.Current.CancellationToken); - var httpRequestException = await Assert.ThrowsAsync(() => ConnectAsync()); + // Session-scoped user validation across requests is a legacy stateful-session behavior; the + // draft revision is sessionless. Pin to the latest stable version to keep covering it. + var httpRequestException = await Assert.ThrowsAsync( + () => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25")); Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode); } @@ -159,6 +162,10 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() var sampleCount = 0; await using var mcpClient = await ConnectAsync(configureClient: options => { + // Server->client sampling over the open response stream is a stateful-session behavior. + // Under draft, Streamable HTTP is forced sessionless, so the implicit-MRTR suspend path + // doesn't apply over HTTP (draft sampling is covered by the stdio MRTR tests). Pin legacy. + options.ProtocolVersion = "2025-11-25"; options.Handlers.SamplingHandler = async (parameters, _, _) => { Assert.NotNull(parameters?.Messages); @@ -319,7 +326,13 @@ await client.CallToolAsync("echo_with_user_name", new Dictionary { ["message"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); - Assert.Contains(RequestMethods.Initialize, observedMethods); + // The client now defaults to the draft revision, whose handshake is server/discover + // rather than the legacy initialize request. On the stateful Streamable HTTP fixture the + // sessionless draft request is refused, so the client downgrades to the legacy initialize. + var expectedHandshakeMethod = UseStreamableHttp && !Stateless + ? RequestMethods.Initialize + : RequestMethods.ServerDiscover; + Assert.Contains(expectedHandshakeMethod, observedMethods); Assert.Contains(RequestMethods.ToolsList, observedMethods); Assert.Contains(RequestMethods.ToolsCall, observedMethods); } @@ -373,6 +386,9 @@ public async Task OutgoingFilter_SeesResponsesAndRequests() await using var client = await ConnectAsync(configureClient: opts => { + // Server-originated sampling requests and the initialize response are legacy stateful + // behaviors; the draft revision routes sampling through MRTR and drops initialize. + opts.ProtocolVersion = "2025-11-25"; opts.Capabilities = clientOptions.Capabilities; opts.Handlers = clientOptions.Handlers; }); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index acdcfa456..eb036ad29 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0 @@ -7,6 +7,8 @@ false true ModelContextProtocol.AspNetCore.Tests + + $(NoWarn);MCP9006 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 6be82aec0..15c415683 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -14,12 +13,19 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Protocol-level tests for Multi Round-Trip Requests (MRTR). -/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior -/// including InputRequiredResult structure, retry with inputResponses, and error handling. +/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the draft revision. +/// Under the draft protocol (SEP-2575 + SEP-2567) Streamable HTTP is sessionless, so these tests +/// drive the default server with raw, sessionless +/// draft JSON-RPC requests (no initialize, no Mcp-Session-Id) and verify the explicit +/// MRTR structure, retry with inputResponses, and error handling. +/// Stateful-session MRTR behaviors (implicit handler suspension, disposal cancellation) are covered +/// over stdio by MrtrHandlerLifecycleTests, and unknown-session rejection by +/// StreamableHttpServerConformanceTests.PostRequest_IsNotFound_WithUnrecognizedSessionId. /// public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private WebApplication? _app; private async Task StartAsync() @@ -31,30 +37,105 @@ private async Task StartAsync() Name = nameof(MrtrProtocolTests), Version = "1", }; - options.ProtocolVersion = "DRAFT-2026-v1"; }).WithTools([ McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => + static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + new McpServerToolCreateOptions + { + Name = "throwing-tool", + Description = "A tool that throws immediately" + }), + McpServerTool.Create( + static CallToolResult (RequestContext context) => { - var result = await server.ElicitAsync(new ElicitRequestParams + // Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState: + // R1 (no requestState) issues a requestState; R2 with a tampered requestState + // surfaces a JSON-RPC error rather than a complete result or a re-prompt. + if (context.Params!.RequestState is { } state) { - Message = message, - RequestedSchema = new() - }, ct); + if (state != "valid-request-state-token") + { + throw new McpProtocolException( + "requestState failed integrity verification.", McpErrorCode.InvalidParams); + } - return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + return new CallToolResult { Content = [new TextContentBlock { Text = "state-ok" }] }; + } + + throw new InputRequiredException( + new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: "valid-request-state-token"); }, new McpServerToolCreateOptions { - Name = "elicit-tool", - Description = "Elicits from client" + Name = "tampered-state-tool", + Description = "Rejects a tampered requestState with a JSON-RPC error" }), McpServerTool.Create( - static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + static CallToolResult (RequestContext context) => + { + // Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithCapabilityCheck: + // emit inputRequests only for capabilities declared on the per-request _meta envelope. + var caps = context.JsonRpcRequest.Context?.ClientCapabilities; + var inputRequests = new Dictionary(); + + if (caps?.Sampling is not null) + { + inputRequests["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }); + } + + if (caps?.Elicitation is not null) + { + inputRequests["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }); + } + + if (inputRequests.Count == 0) + { + return new CallToolResult { Content = [new TextContentBlock { Text = "no-caps" }] }; + } + + throw new InputRequiredException(inputRequests); + }, new McpServerToolCreateOptions { - Name = "throwing-tool", - Description = "A tool that throws immediately" + Name = "capability-check-tool", + Description = "Gates inputRequests on the per-request _meta clientCapabilities envelope" }), ]).WithHttpTransport(); @@ -62,8 +143,12 @@ private async Task StartAsync() _app.MapMcp(); await _app.StartAsync(TestContext.Current.CancellationToken); + // Drive the server with sessionless draft requests: every request carries the draft + // MCP-Protocol-Version header and (via PostJsonRpcAsync) the SEP-2243 Mcp-Method/Mcp-Name + // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); } public async ValueTask DisposeAsync() @@ -79,7 +164,6 @@ public async ValueTask DisposeAsync() public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() { await StartAsync(); - await InitializeWithMrtrAsync(); var response = await PostJsonRpcAsync(CallTool("throwing-tool")); @@ -94,123 +178,71 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() } [Fact] - public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() + public async Task TamperedRequestState_ReturnsJsonRpcError() { await StartAsync(); - await InitializeWithMrtrAsync(); - - // Send a retry with a requestState that doesn't match any active continuation - var retryParams = new JsonObject - { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "test" }, - ["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } }, - ["requestState"] = "nonexistent-state-id" - }; - - var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); - // Read as a generic JsonRpcMessage to check if it's an error - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); - var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); - - // Invalid requestState should result in a fresh tool invocation - // (the tool will return InputRequiredResult since it calls ElicitAsync) - // or an error, depending on the implementation. - // In our implementation, unrecognized requestState triggers a new invocation. - Assert.True( - message is JsonRpcResponse or JsonRpcError, - $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); - } - - [Fact] - public async Task SessionDelete_CancelsPendingMrtrContinuation() - { - await StartAsync(); - await InitializeWithMrtrAsync(); + // Round 1: no requestState -> InputRequiredResult carrying the issued requestState. + using var r1 = await PostJsonRpcAsync(CallTool("tampered-state-tool")); + var r1Response = await AssertSingleSseResponseAsync(r1); + var r1Result = Assert.IsType(r1Response.Result); + Assert.Equal("input_required", r1Result["resultType"]?.GetValue()); - // 1. Call a tool that suspends at ElicitAsync (implicit MRTR path). - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); + var requestState = r1Result["requestState"]!.GetValue(); + var inputKey = r1Result["inputRequests"]!.AsObject().First().Key; - // Verify we got an InputRequiredResult (handler is now suspended, continuation stored). - var resultObj = Assert.IsType(rpcResponse.Result); - Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); - var requestState = resultObj["requestState"]!.GetValue(); - Assert.False(string.IsNullOrEmpty(requestState)); - - // 2. DELETE the session while the handler is suspended. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // Poll for the async cancellation to propagate through the handler task. - // Under thread pool starvation, this can take significantly longer than 100ms. - var deadline = DateTime.UtcNow.AddSeconds(30); - while (true) + // Round 2: tamper the requestState the way the conformance harness does and retry. + // The tool MUST reject it with a JSON-RPC error (not a complete result, not a re-prompt). + var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); + var retryParams = new JsonObject { - if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) - || DateTime.UtcNow >= deadline) + ["name"] = "tampered-state-tool", + ["arguments"] = new JsonObject(), + ["requestState"] = requestState + "-TAMPERED", + ["inputResponses"] = new JsonObject { - break; - } + [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + }, + }; - await Task.Delay(100, TestContext.Current.CancellationToken); - } + using var r2 = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); - // 3. Verify that the MRTR cancellation was logged at Debug level. - var mrtrCancelledLog = MockLoggerProvider.LogMessages - .Where(m => m.Message.Contains("pending MRTR continuation")) - .ToList(); - Assert.Single(mrtrCancelledLog); - Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel); - Assert.Contains("1", mrtrCancelledLog[0].Message); - - // 4. Verify no error-level log was emitted for the cancellation. - // The handler's OperationCanceledException should be silently observed, not logged as an error. - var errorLogs = MockLoggerProvider.LogMessages - .Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit")) - .ToList(); - Assert.Empty(errorLogs); + var sseData = Assert.Single(await ReadSseAsync(r2.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + var error = Assert.IsType(message); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); } [Fact] - public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() + public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities() { await StartAsync(); - await InitializeWithMrtrAsync(); - - // 1. Call a tool that suspends at ElicitAsync. - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); - - var resultObj = Assert.IsType(rpcResponse.Result); - var requestState = resultObj["requestState"]!.GetValue(); - var inputRequests = resultObj["inputRequests"]!.AsObject(); - var inputKey = inputRequests.First().Key; - // 2. DELETE the session. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // 3. Attempt to retry with the old requestState - session is gone. - var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); - var retryParams = new JsonObject + // Per SEP-2575 the client declares capabilities per request in + // _meta['io.modelcontextprotocol/clientCapabilities']. Declare ONLY sampling: the tool + // must emit a sampling/createMessage inputRequest but no elicitation/create. + var callParams = new JsonObject { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "Please confirm" }, - ["requestState"] = requestState, - ["inputResponses"] = new JsonObject + ["name"] = "capability-check-tool", + ["arguments"] = new JsonObject(), + ["_meta"] = new JsonObject { - [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + ["io.modelcontextprotocol/clientCapabilities"] = new JsonObject + { + ["sampling"] = new JsonObject(), + }, }, }; - using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + using var response = await PostJsonRpcAsync(Request("tools/call", callParams.ToJsonString())); + var rpcResponse = await AssertSingleSseResponseAsync(response); + var resultObj = Assert.IsType(rpcResponse.Result); + Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); - // The session was deleted, so we should get a 404 with a JSON-RPC error. - Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode); - Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType); + var inputRequests = resultObj["inputRequests"]!.AsObject(); + Assert.Contains(inputRequests, kvp => kvp.Value!["method"]?.GetValue() == "sampling/createMessage"); + Assert.DoesNotContain(inputRequests, kvp => kvp.Value!["method"]?.GetValue() == "elicitation/create"); } /// @@ -229,9 +261,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 +294,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 +427,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) @@ -437,33 +469,4 @@ private string CallTool(string toolName, string arguments = "{}") => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} """); - - /// - /// Initialize a session requesting the experimental protocol version that enables MRTR. - /// - 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"}}} - """; - - using var response = await PostJsonRpcAsync(initJson); - var rpcResponse = await AssertSingleSseResponseAsync(response); - Assert.NotNull(rpcResponse.Result); - - // Verify the server negotiated to the experimental version - var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); - Assert.Equal("DRAFT-2026-v1", protocolVersion); - - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); - - // Set the MCP-Protocol-Version header for subsequent requests - HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); - - // 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..4ec8ba4d9 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -0,0 +1,219 @@ +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 DraftPost_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32001() + { + await StartAsync(); + + // The MCP-Protocol-Version header declares the draft revision, but the per-request _meta declares a + // different (still individually supported) version. Per SEP-2575 the server MUST reject the + // disagreement. It uses -32001 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body + // checks) so a conformant draft client surfaces the error instead of mistaking the modern server for a + // legacy one and falling back to the initialize handshake. + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + + DraftMetaFragment("2025-11-25") + "}}"; + + 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); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [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/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs new file mode 100644 index 000000000..501d698a6 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -0,0 +1,162 @@ +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.Http.Headers; +using System.Text; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Verifies that aborting an HTTP request flows cancellation into the running request handler's +/// . +/// +/// Under the draft protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime is the +/// request lifetime: there are no sessions, so a dropped connection is equivalent to cancelling the +/// in-flight request. The same holds for legacy stateless mode, where each request is independent and +/// outlived by nothing. These tests pin that behavior so a tool's fires +/// promptly when the client goes away. +/// +/// +public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private WebApplication? _app; + + private readonly TaskCompletionSource _toolStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _toolCanceled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _requestAborted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private async Task StartAsync(bool stateless) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" }; + }) + .WithHttpTransport(options => + { + options.Stateless = stateless; + }) + .WithTools([McpServerTool.Create( + async (CancellationToken cancellationToken) => + { + _toolStarted.TrySetResult(); + try + { + // Block until the request handler's CancellationToken fires. If cancellation never + // flows from the aborted HTTP request, this hangs and the test times out. + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + _toolCanceled.TrySetResult(); + throw; + } + + return "unreachable"; + }, + new() { Name = "blockingTool" })]); + + _app = Builder.Build(); + + // Record when the server observes the client abort so we can assert the abort (not some unrelated + // cancellation path) is what tears down the in-flight tool. + _app.Use(async (context, next) => + { + context.RequestAborted.Register(() => _requestAborted.TrySetResult()); + await next(); + }); + + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task DraftSessionlessRequest_AbortFlowsCancellationToToolHandler() + { + // Draft is sessionless (SEP-2567) and is served natively only on a stateless server; a + // Stateless=false server refuses sessionless draft so dual-era clients fall back to initialize. + await StartAsync(stateless: true); + + using var request = CreateBlockingToolRequest(draft: true); + + await AssertAbortCancelsToolAsync(request); + } + + [Fact] + public async Task StatelessRequest_AbortFlowsCancellationToToolHandler() + { + await StartAsync(stateless: true); + + using var request = CreateBlockingToolRequest(draft: false); + + await AssertAbortCancelsToolAsync(request); + } + + private static HttpRequestMessage CreateBlockingToolRequest(bool draft) + { + // Draft tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta + // (protocol version, client info, capabilities) that replaces the initialize handshake (SEP-2567). + var body = draft + ? """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"DRAFT_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """.Replace("DRAFT_VERSION", DraftVersion) + : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; + + var request = new HttpRequestMessage(HttpMethod.Post, "") + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (draft) + { + request.Headers.Add("MCP-Protocol-Version", DraftVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "blockingTool"); + } + + return request; + } + + private async Task AssertAbortCancelsToolAsync(HttpRequestMessage request) + { + using var requestCts = new CancellationTokenSource(); + + // Send the request without awaiting completion. The blockingTool will not return until its + // CancellationToken fires, so this Task only completes once we abort the request below. + // ResponseContentRead (the default) keeps SendAsync pending on the response body, so cancelling + // requestCts actually aborts the in-flight connection. (With ResponseHeadersRead, SendAsync would + // return as soon as the server flushed the SSE response headers and the cancel would be a no-op.) + var sendTask = HttpClient.SendAsync(request, requestCts.Token); + + // Wait for the server to actually start running the tool before aborting. + await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // Abort the in-flight HTTP request, simulating the client disconnecting. + requestCts.Cancel(); + + // The server must observe the abort... + await _requestAborted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // ...and that abort must cancel the running tool's CancellationToken. + await _toolCanceled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The HttpClient call itself should observe the cancellation we requested. + await Assert.ThrowsAnyAsync(() => sendTask); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 9738ffda3..c79207c2f 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); }) @@ -515,7 +517,11 @@ protected async Task ConnectClientAsync() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - return await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + // Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the draft revision + // (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful, + // resumable legacy handshake instead of the sessionless draft default. + return await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, + loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index e996ffd1d..c0990737e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -143,7 +143,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - // SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it + // 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). @@ -151,7 +151,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() TestContext.Current.CancellationToken, basePort: 3021); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1"); + $"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}"); @@ -169,32 +169,48 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() TestContext.Current.CancellationToken, basePort: 3024); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1"); + $"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 ). + // + // input-required-result-tampered-state and input-required-result-capability-check are + // implemented by ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState + // (HMAC-protected requestState; a tampered requestState surfaces a -32602 JSON-RPC error) + // and ToolWithCapabilityCheck (gates inputRequests on the per-request + // _meta clientCapabilities envelope). Both behaviors also have in-process wire-level + // regression coverage in MrtrProtocolTests so they stay verified even while the published + // conformance package's draft wire string lags this SDK. [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")] + [InlineData("input-required-result-capability-check")] + [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}"); 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/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 80c37ea61..52b0d3685 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -4,8 +4,12 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; using System.Diagnostics; using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -318,6 +322,64 @@ public async Task StatelessMode_DoesNotAdvertise_ListChangedCapabilities() Assert.Null(client.ServerCapabilities.Resources?.ListChanged); } + [Fact] + public async Task SubscriptionsListen_InStatelessMode_GrantsNothing_AndDoesNotHoldRequestOpen() + { + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) + .WithPrompts([McpServerPrompt.Create(() => new GetPromptResult(), new() { Name = "myPrompt" })]) + .WithResources([McpServerResource.Create(() => new ReadResourceResult(), new() { UriTemplate = "resource://test" })]); + + _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")); + + await using var client = await ConnectMcpClientAsync(); + + var ackChannel = Channel.CreateUnbounded(); + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + + // Request every kind of subscription the protocol exposes, even though the server registers + // subscribable primitives. A stateless session cannot push out-of-band notifications, so the + // request must acknowledge with no grants and complete promptly instead of holding the POST + // (and its request scope) open forever - a regression would hang here until the timeout. + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + PromptsListChanged = true, + ResourcesListChanged = true, + ResourceSubscriptions = ["resource://test"], + }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The acknowledgement is sent before the response completes, so it is already buffered here. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var grantedNotifications = Assert.IsType(Assert.IsType(ack.Params)["notifications"]); + Assert.Null(grantedNotifications["toolsListChanged"]); + Assert.Null(grantedNotifications["promptsListChanged"]); + Assert.Null(grantedNotifications["resourcesListChanged"]); + Assert.Null(grantedNotifications["resourceSubscriptions"]); + } + [McpServerTool(Name = "testSamplingErrors")] public static async Task TestSamplingErrors(McpServer server) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 517d41e02..849941d8e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -55,7 +55,7 @@ private async Task StartAsync(bool enableDelete = false) Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new(), @@ -138,7 +138,7 @@ public async Task CanCallToolOnSessionlessStreamableHttpServer() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var echoTool = Assert.Single(tools); @@ -158,7 +158,7 @@ public async Task CanCallToolConcurrently() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var echoTool = Assert.Single(tools); @@ -184,7 +184,7 @@ public async Task SendsDeleteRequestOnDispose() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Dispose should trigger DELETE request await client.DisposeAsync(); @@ -206,7 +206,7 @@ public async Task DoesNotSendDeleteWhenTransportDoesNotOwnSession() OwnsSession = false, }, HttpClient, LoggerFactory); - await using (await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { // No-op. Disposing the client should not trigger a DELETE request. } @@ -277,7 +277,7 @@ public async Task CreateAsyncWithKnownSessionIdThrows() }, HttpClient, LoggerFactory); var exception = await Assert.ThrowsAsync(() => - McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains(nameof(McpClient.ResumeSessionAsync), exception.Message); } @@ -311,7 +311,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetS Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "hang-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -358,7 +358,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetS OwnsSession = false, }, HttpClient, LoggerFactory); - await using (var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Single(tools); @@ -403,7 +403,7 @@ public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "expiry-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -421,7 +421,7 @@ public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("expiry-test-session", client.SessionId); Assert.False(client.Completion.IsCompleted); @@ -464,7 +464,7 @@ public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "get-expiry-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -489,7 +489,7 @@ public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("get-expiry-test", client.SessionId); // Trigger session expiry on the GET SSE stream @@ -512,7 +512,7 @@ public async Task Completion_GracefulDisposal_ReturnsCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.False(client.Completion.IsCompleted); await client.DisposeAsync(); @@ -563,7 +563,7 @@ public async Task ListTools_FiltersToolsWithInvalidHeaderAnnotations() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); // The server returns 3 tools: valid_tool, invalid_space_tool, invalid_duplicate_tool @@ -587,7 +587,7 @@ public async Task Client_SendsCorrectHeaders_EndToEnd() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var tool = Assert.Single(tools); @@ -628,7 +628,7 @@ private async Task StartHeaderToolServer() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2025-11-25", 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 = "2025-11-25", 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..d3a3681dd 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; @@ -27,7 +27,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(bool stateless = false) { Builder.Services.AddMcpServer(options => { @@ -36,7 +36,7 @@ private async Task StartAsync() Name = nameof(StreamableHttpServerConformanceTests), Version = "73", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = stateless); _app = Builder.Build(); @@ -65,7 +65,7 @@ public async Task NegativeNonInfiniteIdleTimeout_Throws_ArgumentOutOfRangeExcept options.IdleTimeout = TimeSpan.MinValue; }); - var ex = await Assert.ThrowsAnyAsync(StartAsync); + var ex = await Assert.ThrowsAnyAsync(() => StartAsync()); Assert.Contains("IdleTimeout", ex.Message); } @@ -77,7 +77,7 @@ public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException options.MaxIdleSessionCount = -1; }); - var ex = await Assert.ThrowsAnyAsync(StartAsync); + var ex = await Assert.ThrowsAnyAsync(() => StartAsync()); Assert.Contains("MaxIdleSessionCount", ex.Message); } @@ -247,6 +247,38 @@ public async Task PostWithoutSessionId_NonInitializeRequest_Returns400() Assert.Contains("Stateless", body); } + [Fact] + public async Task PostMalformedJson_Returns400_InvalidRequest_WithNullId() + { + await StartAsync(); + + using var response = await HttpClient.PostAsync("", JsonContent("{ this is not valid json"), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // The server must emit a conformant JSON-RPC error envelope (not a raw 500). Because the request + // id could not be read, the error carries id=null per JSON-RPC 2.0 §5.1 — and crucially it must + // serialize as JSON null, not "" (regression guard for the RequestId write path). + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); + Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public async Task PostRequestWithExplicitNullId_Returns400_InvalidRequest_WithNullId() + { + await StartAsync(); + + // A request carrying an explicit `id:null` is malformed per the MCP base protocol ("the ID MUST + // NOT be null") and must NOT be silently treated as a notification. The server rejects it with a + // conformant 400 InvalidRequest error whose own id is null. + using var response = await HttpClient.PostAsync("", JsonContent("""{"jsonrpc":"2.0","id":null,"method":"tools/list"}"""), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); + Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + [Fact] public async Task GetWithoutSessionId_Returns400_WithStatelessGuidance() { @@ -886,7 +918,8 @@ public async Task McpServer_UsedOutOfScope_CanSendNotifications() [Fact] public async Task DraftVersion_RejectsMissingMcpMethodHeader() { - await StartAsync(); + // Draft is sessionless and served only on a stateless server (SEP-2567). + await StartAsync(stateless: true); // Initialize with draft version to enable header validation await CallInitializeWithDraftVersionAndValidateAsync(); @@ -894,7 +927,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); @@ -904,13 +937,13 @@ public async Task DraftVersion_RejectsMissingMcpMethodHeader() [Fact] public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() { - await StartAsync(); + await StartAsync(stateless: true); await CallInitializeWithDraftVersionAndValidateAsync(); // 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); @@ -920,13 +953,13 @@ public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() [Fact] public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() { - await StartAsync(); + await StartAsync(stateless: true); await CallInitializeWithDraftVersionAndValidateAsync(); // 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 +989,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.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 7ce848907..342cf9743 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -36,6 +36,16 @@ }, }; +// The default client now prefers the draft revision (probing with server/discover and falling back +// to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios specifically exercise +// the legacy initialize handshake and SSE resumability (removed in draft) and strictly expect +// initialize as the first message, so pin them to the latest stable version. Other scenarios run on +// the draft default and exercise the server/discover probe plus the transparent legacy fallback. +if (scenario is "initialize" or "sse-retry") +{ + options.ProtocolVersion = "2025-11-25"; +} + var consoleLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 15b2c87f2..c81d8d262 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,6 +5,7 @@ enable enable Exe + $(NoWarn);MCP9006 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..99a770527 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -4,6 +4,8 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -20,8 +22,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 +53,8 @@ public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -81,8 +83,8 @@ public static CallToolResult ToolWithSampling(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -102,7 +104,7 @@ public static CallToolResult ToolWithListRoots(RequestContext context) { @@ -135,7 +137,7 @@ public static CallToolResult ToolWithRequestState(RequestContext context) { @@ -177,7 +179,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) { @@ -263,6 +265,137 @@ public static CallToolResult ToolForMissingResponse(RequestContext context) + { + if (context.Params!.RequestState is { } state) + { + if (!VerifyRequestState(state)) + { + throw new McpProtocolException( + "requestState failed integrity verification (tampered or invalid signature).", + McpErrorCode.InvalidParams); + } + + return TextResult("tampered-state-ok: requestState integrity verified"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: SignRequestState()); + } + + // ──── A13: Respect client capabilities ────────────────────────────────── + // Per SEP-2575 the client declares its capabilities in the per-request + // _meta['io.modelcontextprotocol/clientCapabilities'] envelope (surfaced on + // JsonRpcMessageContext.ClientCapabilities). The server MUST only emit inputRequests + // for capabilities the client advertised on this request. + [McpServerTool(Name = "test_input_required_result_capabilities")] + [Description("SEP-2322 A13: returns inputRequests only for the capabilities the client declared in per-request _meta.")] + public static CallToolResult ToolWithCapabilityCheck(RequestContext context) + { + if (context.Params!.InputResponses is { Count: > 0 }) + { + return TextResult("capability-check-ok: received input responses"); + } + + var capabilities = context.JsonRpcRequest.Context?.ClientCapabilities; + var inputRequests = new Dictionary(); + + if (capabilities?.Sampling is not null) + { + inputRequests["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }); + } + + if (capabilities?.Elicitation is not null) + { + inputRequests["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }); + } + + if (capabilities?.Roots is not null) + { + inputRequests["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()); + } + + if (inputRequests.Count == 0) + { + return TextResult("capability-check-ok: client declared no MRTR-capable features"); + } + + throw new InputRequiredException(inputRequests); + } + + private static string SignRequestState() + { + var nonce = Guid.NewGuid().ToString("N"); + return $"{nonce}.{ComputeSignature(nonce)}"; + } + + private static bool VerifyRequestState(string state) + { + var separator = state.LastIndexOf('.'); + if (separator <= 0 || separator == state.Length - 1) + { + return false; + } + + var nonce = state[..separator]; + var signature = state[(separator + 1)..]; + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(signature), + Encoding.UTF8.GetBytes(ComputeSignature(nonce))); + } + + private static string ComputeSignature(string nonce) + { + using var hmac = new HMACSHA256(s_requestStateKey); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(nonce))); + } + private static CallToolResult TextResult(string text) => new() { Content = [new TextContentBlock { Text = text }], 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..659bfa4b0 --- /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(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + 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(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + 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..71215cdad --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs @@ -0,0 +1,181 @@ +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; + private const string LatestStableVersion = "2025-11-25"; + + // 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(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var meta = _capturedMeta[RequestMethods.ToolsList]; + if (meta is not null) + { + Assert.False(meta.ContainsKey(MetaKeys.ProtocolVersion)); + Assert.False(meta.ContainsKey(MetaKeys.ClientInfo)); + Assert.False(meta.ContainsKey(MetaKeys.ClientCapabilities)); + } + } + + private void AssertDraftMetaPresent(string method) + { + Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}"); + Assert.NotNull(meta); + Assert.True(meta!.ContainsKey(MetaKeys.ProtocolVersion), + $"Missing protocolVersion key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(MetaKeys.ClientInfo), + $"Missing clientInfo key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(MetaKeys.ClientCapabilities), + $"Missing clientCapabilities key on {method} _meta envelope"); + + // The protocolVersion value must match the negotiated draft version. + Assert.Equal(DraftVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs new file mode 100644 index 000000000..ee624ede0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs @@ -0,0 +1,272 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Diagnostics; +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); + } + + [Fact] + public async Task DraftClient_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() + { + // Simulate a legacy server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to legacy initialize once the configured + // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + silentDiscoverProbe: true); + + var stopwatch = Stopwatch.StartNew(); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250), + InitializationTimeout = TestConstants.DefaultTimeout, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + stopwatch.Stop(); + + Assert.True(transport.ServerDiscoverProbed); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(30), + $"Fallback should have happened shortly after the {nameof(McpClientOptions.DiscoverProbeTimeout)}, but took {stopwatch.Elapsed}."); + } + + [Theory] + [InlineData(0)] + [InlineData(-1000)] + public void DiscoverProbeTimeout_Setter_Rejects_NonPositiveValues(int milliseconds) + { + var options = new McpClientOptions(); + Assert.Throws(() => options.DiscoverProbeTimeout = TimeSpan.FromMilliseconds(milliseconds)); + } + + [Fact] + public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() + { + var options = new McpClientOptions(); + + // Default is the documented 5 seconds. + Assert.Equal(TimeSpan.FromSeconds(5), options.DiscoverProbeTimeout); + + options.DiscoverProbeTimeout = TimeSpan.FromSeconds(30); + Assert.Equal(TimeSpan.FromSeconds(30), options.DiscoverProbeTimeout); + + // Timeout.InfiniteTimeSpan disables the separate probe timeout (bounded by InitializationTimeout only). + options.DiscoverProbeTimeout = Timeout.InfiniteTimeSpan; + Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout); + } + + /// + /// Minimal in-memory transport that simulates a legacy server: rejects + /// server/discover (with a configurable JSON-RPC error code, or by + /// silently dropping the request) and responds to initialize with a + /// configurable protocol version. + /// + private sealed class LegacyServerTestTransport( + string serverNegotiatedVersion, + int probeErrorCode = (int)McpErrorCode.MethodNotFound, + bool silentDiscoverProbe = false) : 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; + if (silentDiscoverProbe) + { + // Model a legacy server that drops the unknown method without replying. + break; + } + + _ = 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/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index b2935d247..1a0785630 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -171,10 +171,27 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c { switch (message) { - case JsonRpcRequest: + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverRequest: _channel.Writer.TryWrite(new JsonRpcResponse { - Id = ((JsonRpcRequest)message).Id, + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + Capabilities = new ServerCapabilities(), + SupportedVersions = [McpSession.DraftProtocolVersion], + ServerInfo = new Implementation + { + Name = "NopTransport", + Version = "1.0.0" + }, + }, McpJsonUtilities.DefaultOptions), + }); + break; + + case JsonRpcRequest request: + _channel.Writer.TryWrite(new JsonRpcResponse + { + Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { Capabilities = new ServerCapabilities(), diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 7e67eb44c..18cd5e232 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -2,12 +2,17 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; public class McpClientMetaTests : ClientServerTestBase { + // InitializeMeta is carried on the legacy initialize request, which the draft revision removes. + // The two InitializeMeta_* tests pin to the latest stable version so the handshake actually runs. + private const string LatestStableVersion = "2025-11-25"; + private readonly TaskCompletionSource _initializeMeta = new(); public McpClientMetaTests(ITestOutputHelper outputHelper) @@ -50,6 +55,7 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() { var clientOptions = new McpClientOptions { + ProtocolVersion = LatestStableVersion, InitializeMeta = new JsonObject { { "foo", "bar baz" } @@ -58,7 +64,7 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() await using McpClient client = await CreateMcpClientForServer(clientOptions); - var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.NotNull(meta); Assert.Equal("bar baz", meta["foo"]?.ToString()); @@ -67,9 +73,9 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() [Fact] public async Task InitializeMeta_IsOmitted_WhenNotSet() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.Null(meta); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 749ef51eb..e3d90bced 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -584,15 +584,16 @@ public async Task AsClientLoggerProvider_MessagesSentToClient() public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) { await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = protocolVersion }); - Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion); + // A null ProtocolVersion now prefers the draft revision, which the reactive test server advertises. + Assert.Equal(protocolVersion ?? "2026-07-28", client.NegotiatedProtocolVersion); } [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] @@ -794,7 +795,9 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - await using McpClient client = await CreateMcpClientForServer(); + // ping is a legacy-only RPC (removed in the draft revision per SEP-2575), so pin the client + // to a legacy protocol version to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); 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..b22bcffc3 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); + await WriteJsonRpcAsync(serverWriter, discoverResponse); - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); - - // 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,8 +250,9 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Client does NOT set DRAFT-2026-v1 - standard protocol only - var clientOptions = new McpClientOptions(); + // Client is pinned to a legacy protocol version, so it performs the initialize + // handshake and the session is treated as non-MRTR. + var clientOptions = new McpClientOptions { ProtocolVersion = "2025-03-26" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { @@ -408,7 +406,9 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions(); + // Pin to the draft revision so the client performs the server/discover handshake and + // treats InputRequiredResult as an MRTR round-trip. + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { @@ -431,30 +431,27 @@ 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. - 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); + // server/discover handshake - negotiate 2026-07-28 so the client treats InputRequiredResult as MRTR. + var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(discoverLine); + var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverRequest); + Assert.Equal("server/discover", discoverRequest.Method); - var initResponse = new JsonRpcResponse + 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 = ["2026-07-28"], Capabilities = new ServerCapabilities { Tools = new() }, ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); + await WriteJsonRpcAsync(serverWriter, discoverResponse); 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/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 70553ee45..3d62f8636 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,7 +33,10 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - await using var client = await _fixture.CreateClientAsync(clientId); + // ping was removed in the draft revision (SEP-2575), so pin to the latest stable + // protocol version to keep exercising the legacy ping RPC. Draft liveness relies on + // the transport/request lifecycle instead of an explicit ping. + await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index f339e0f20..3b20f39c2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -11,6 +11,8 @@ namespace ModelContextProtocol.Tests.Configuration; public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper, startServer: false) { + private const string LatestStableVersion = "2025-11-25"; + private static ILogger GetLogger(IServiceProvider? services, string categoryName) { var loggerFactory = services?.GetRequiredService() ?? throw new InvalidOperationException("LoggerFactory not available"); @@ -72,22 +74,25 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() { List messageTypes = []; - // The client sends notifications/initialized fire-and-forget, so unlike the initialize and - // tools/list request/response exchanges it has no synchronization point the test can await. - // Signal once the filter finishes processing it so the strict counts below observe a - // complete, stable log instead of racing the still-in-flight notification. - var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // fire-and-forget initialized notification), so the tools/list request is a deterministic + // synchronization point. Gate recording to it and signal once the filter finishes so a + // regression that invokes the filter pipeline more than once per message surfaces as an extra entry. + var toolsListProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); McpServerBuilder .WithMessageFilters(filters => filters.AddIncomingFilter((next) => async (context, cancellationToken) => { - var messageTypeName = context.JsonRpcMessage.GetType().Name; - messageTypes.Add(messageTypeName); + if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }) + { + messageTypes.Add(context.JsonRpcMessage.GetType().Name); + } + await next(context, cancellationToken); - if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }) { - initializedNotificationProcessed.TrySetResult(true); + toolsListProcessed.TrySetResult(true); } })) .WithTools(); @@ -98,51 +103,57 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // Wait for the fire-and-forget initialized notification to flow through the filter pipeline - // before snapshotting the counts; otherwise the strict counts below can race the notification. - await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // The message filter should intercept JsonRpcRequest messages. - // Use strict counts so a regression that invokes the filter pipeline more than once per - // incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing - // side) would fail this test instead of slipping through Assert.Contains. - // A single ListToolsAsync drives three server-bound messages: initialize (request), - // notifications/initialized (notification), and tools/list (request). - Assert.Equal(2, messageTypes.Count(m => m == nameof(JsonRpcRequest))); - Assert.Equal(1, messageTypes.Count(m => m == nameof(JsonRpcNotification))); + await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The message filter should intercept the tools/list JsonRpcRequest exactly once. + Assert.Collection(messageTypes, m => Assert.Equal(nameof(JsonRpcRequest), m)); } [Fact] public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() { - // The client sends notifications/initialized fire-and-forget, so unlike the initialize and - // tools/list request/response exchanges it has no synchronization point the test can await. - // Signal once the outermost filter finishes processing it so the strict counts below observe a - // complete, stable log instead of racing the still-in-flight notification. - var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // fire-and-forget initialized notification), so the tools/list request is a deterministic + // synchronization point. Gate the filter logging to it and signal once the outermost filter + // finishes so the assertions observe a complete, stable log. + var toolsListProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); McpServerBuilder .WithMessageFilters(filters => { filters.AddIncomingFilter((next) => async (context, cancellationToken) => { + var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }; var logger = GetLogger(context.Services, "MessageFilter1"); - logger.LogInformation("MessageFilter1 before"); + if (isToolsList) + { + logger.LogInformation("MessageFilter1 before"); + } + await next(context, cancellationToken); - logger.LogInformation("MessageFilter1 after"); - if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + if (isToolsList) { - initializedNotificationProcessed.TrySetResult(true); + logger.LogInformation("MessageFilter1 after"); + toolsListProcessed.TrySetResult(true); } }); filters.AddIncomingFilter((next) => async (context, cancellationToken) => { + var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }; var logger = GetLogger(context.Services, "MessageFilter2"); - logger.LogInformation("MessageFilter2 before"); + if (isToolsList) + { + logger.LogInformation("MessageFilter2 before"); + } + await next(context, cancellationToken); - logger.LogInformation("MessageFilter2 after"); + + if (isToolsList) + { + logger.LogInformation("MessageFilter2 after"); + } }); }) .WithTools(); @@ -153,38 +164,23 @@ public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // Wait for the fire-and-forget initialized notification to flow through the filter pipeline - // before snapshotting the log; otherwise the strict counts below can race the notification. - await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + // Wait for the outermost filter to finish processing the tools/list request before + // snapshotting the log; otherwise the assertions can race the still-in-flight "after" logs. + await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); var logMessages = MockLoggerProvider.LogMessages .Where(m => m.Category.StartsWith("MessageFilter")) .Select(m => m.Message) .ToList(); - // First filter registered is outermost - // We should see this pattern for each message: MessageFilter1 before -> MessageFilter2 before -> MessageFilter2 after -> MessageFilter1 after - int idx1Before = logMessages.IndexOf("MessageFilter1 before"); - int idx2Before = logMessages.IndexOf("MessageFilter2 before"); - int idx2After = logMessages.IndexOf("MessageFilter2 after"); - int idx1After = logMessages.IndexOf("MessageFilter1 after"); - - Assert.True(idx1Before >= 0); - Assert.True(idx2Before >= 0); - Assert.True(idx2After >= 0); - Assert.True(idx1After >= 0); - - // Verify ordering within a single request - Assert.True(idx1Before < idx2Before); - Assert.True(idx2Before < idx2After); - Assert.True(idx2After < idx1After); - - // Verify each filter ran exactly once per incoming message (initialize + notifications/initialized + tools/list). - // Strict counts catch regressions where the incoming filter pipeline gets invoked more than once per message. - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 before")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 before")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 after")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 after")); + // First filter registered is outermost. For the single gated tools/list request we expect the + // strict nested order. Assert.Collection also catches any regression that invokes the incoming + // filter pipeline more than once per message (which would add extra entries). + Assert.Collection(logMessages, + m => Assert.Equal("MessageFilter1 before", m), + m => Assert.Equal("MessageFilter2 before", m), + m => Assert.Equal("MessageFilter2 after", m), + m => Assert.Equal("MessageFilter1 after", m)); } [Fact] @@ -396,6 +392,11 @@ public async Task AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requ var clientOptions = new McpClientOptions { + // This test observes the legacy outgoing flow on the server side: the initialize response and + // the server->client sampling/createMessage request. Under the draft protocol those are replaced + // by server/discover and implicit MRTR (InputRequiredResult), which is covered by MrtrIntegrationTests. + // Pin to the latest stable version to keep exercising the legacy server->client request path here. + ProtocolVersion = LatestStableVersion, Capabilities = new() { Sampling = new() }, Handlers = new() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 209469e7b..795b380a4 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -128,7 +128,14 @@ public async Task Can_List_And_Call_Registered_Prompts() [Fact] public async Task Can_Be_Notified_Of_Prompt_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(8, prompts.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d6bb239d7..5630d2b77 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -162,7 +162,14 @@ public async Task Can_List_And_Call_Registered_ResourceTemplates() [Fact] public async Task Can_Be_Notified_Of_Resource_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(7, resources.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index d2db4c62c..79eace963 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -188,7 +188,13 @@ public async Task Can_Create_Multiple_Servers_From_Options_And_List_Registered_T [Fact] public async Task Can_Be_Notified_Of_Tool_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(19, tools.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 1956887ac..9a78045b9 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -58,7 +58,11 @@ private async Task AssertMatchAsync( } /// - /// Asserts that the given URI does NOT match the template. + /// Asserts that the given URI does NOT match the template. Uses the default client, which + /// negotiates the draft protocol revision, so the unknown-resource response carries the + /// standard JSON-RPC (-32602). The version-gated + /// legacy mapping to (-32002) is covered by + /// . /// private async Task AssertNoMatchAsync( string uriTemplate, @@ -71,7 +75,7 @@ private async Task AssertNoMatchAsync( var ex = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(uri, null, TestContext.Current.CancellationToken)); - Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, ex.ErrorCode); } // Unknown-resource-URI responses are version-gated: older clients keep the legacy @@ -79,7 +83,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( @@ -130,7 +134,9 @@ public async Task MultipleTemplatedResources_MatchesCorrectResource() // Literal template braces in URI should not match (template literal is not a valid URI) var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); - Assert.Equal(McpErrorCode.ResourceNotFound, mcpEx.ErrorCode); + // Draft maps an unmatched resource URI to InvalidParams (-32602); the legacy -32002 ResourceNotFound + // mapping is covered by the version-gated ResourceNotFound_ErrorCode_IsVersionGated theory. + Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); } diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index 03e00af8d..acaa1b9ae 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -91,10 +91,13 @@ await WaitForAsync( Assert.Equal(clientListToolsCall.SpanId, serverListToolsCall.ParentSpanId); Assert.Equal(clientListToolsCall.TraceId, serverListToolsCall.TraceId); - // Validate that the client trace context encoded to request.params._meta[traceparent] + // Validate that the client trace context encoded to request.params._meta[traceparent]. + // Under the draft revision _meta also carries the per-request envelope (protocolVersion, + // clientInfo, clientCapabilities), so assert on the traceparent property specifically + // rather than the entire _meta object. using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\""))); - var metaJson = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetRawText(); - Assert.Equal($$"""{"traceparent":"00-{{clientListToolsCall.TraceId}}-{{clientListToolsCall.SpanId}}-01"}""", metaJson); + var traceparent = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetProperty("traceparent").GetString(); + Assert.Equal($"00-{clientListToolsCall.TraceId}-{clientListToolsCall.SpanId}-01", traceparent); // Validate that mcp.session.id is set on both client and server activities and that // all client activities share one session ID while all server activities share another. diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 6025bc316..4b782cd64 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902 + $(NoWarn);NU1903;NU1902;MCP9006 $(DefineConstants);MCP_TEST_TIME_PROVIDER diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs index ac40bd767..614af34c7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs @@ -84,6 +84,9 @@ public async Task InitializeTimeout_DoesNotSendCancellationNotification() var clientOptions = new McpClientOptions { + // Pin to a legacy protocol version so the client performs the initialize handshake + // (the spec rule under test is "the initialize request MUST NOT be cancelled by clients"). + ProtocolVersion = "2025-11-25", InitializationTimeout = TimeSpan.FromMilliseconds(500), }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs new file mode 100644 index 000000000..1e32062da --- /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 + { + [MetaKeys.ProtocolVersion] = "2026-07-28", + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0", + }, + [MetaKeys.ClientCapabilities] = 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[MetaKeys.ProtocolVersion]!); + } + + [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/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 7b55b738a..60b8093a7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -287,6 +287,8 @@ public async Task Elicit_Typed_With_Nullable_Property_Type_Throws() var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("Nullable", ex.Message); } [Fact] @@ -340,7 +342,7 @@ public sealed class CamelForm public sealed class NullablePropertyForm { public string? FirstName { get; set; } - public int ZipCode { get; set; } + public int? ZipCode { get; set; } public bool IsAdmin { get; set; } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs index ddab6b142..ab172aa1e 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs @@ -760,4 +760,71 @@ 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); + } + + [Fact] + public static void Deserialize_RequestWithExplicitNullId_Throws() + { + // A message carrying a `method` and an explicit `id:null` is a malformed request. Per the MCP + // base protocol the request id "MUST NOT be null", and a null id does NOT denote a notification + // (JSON-RPC 2.0 notifications omit the id member entirely). The converter must reject it rather + // than silently downgrading to a notification (which would swallow the id and skip the response). + string json = """{"jsonrpc":"2.0","id":null,"method":"tools/list"}"""; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void Deserialize_RequestWithExplicitNullIdAndParams_Throws() + { + string json = """{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"echo"}}"""; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void Deserialize_NotificationWithoutIdMember_IsNotConfusedWithNullIdRequest() + { + // Contrast with the explicit-null-id case above: omitting the id member entirely is a valid + // notification and must continue to deserialize as one. + string json = """{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var notification = Assert.IsType(message); + Assert.Equal("notifications/cancelled", notification.Method); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs index e426c7469..ba9120cb3 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs @@ -35,4 +35,32 @@ 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); + } + + [Fact] + public void Null_SerializesAsJsonNull() + { + // The default RequestId (Id == null) is the id-less-error-response shape. It MUST serialize as + // JSON null — not "" — so the wire form is spec-conformant and round-trips losslessly. + Assert.Equal("null", JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public void Null_Roundtrips() + { + var json = JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions); + var id = JsonSerializer.Deserialize(json, 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..c2d48b888 --- /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 + { + [MetaKeys.ProtocolVersion] = "2026-07-28", + [MetaKeys.LogLevel] = "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![MetaKeys.ProtocolVersion]!); + } + + [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..f2cbfef6e 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. @@ -250,6 +250,29 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() // The client call should fail (server disposed mid-MRTR). await Assert.ThrowsAnyAsync(async () => await callTask); + + // Disposing the server while a continuation is suspended should log the cancellation of the + // pending MRTR continuation once at Debug level (this is the only path that reaches the + // continuation-cancellation log now that HTTP draft is always sessionless). Poll for the + // async cancellation to propagate through the handler task. + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) + && DateTime.UtcNow < deadline) + { + await Task.Delay(50, TestContext.Current.CancellationToken); + } + + var mrtrCancelledLog = MockLoggerProvider.LogMessages + .Where(m => m.Message.Contains("pending MRTR continuation")) + .ToList(); + var log = Assert.Single(mrtrCancelledLog); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Contains("1", log.Message); + + // The handler's OperationCanceledException must be silently observed during disposal, not + // logged as an error. + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel >= LogLevel.Error && m.Message.Contains("cancellation-test-tool")); } [Fact] @@ -263,7 +286,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 +324,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 +368,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 +410,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 +440,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/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs new file mode 100644 index 000000000..ff48b71c8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.ComponentModel; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies the server establishes its negotiated protocol version exactly once per stateful session: the +/// initial -to-value transition is allowed and re-sending the same version is an +/// idempotent no-op, but a request that switches to a different (even if otherwise supported) version is +/// rejected with . The session is driven over a raw stream +/// transport (the stdio-shaped, stateful path) so the per-request _meta protocol version is fully +/// controlled - the SDK's own client normalizes it on every outgoing request, so only a misbehaving peer +/// can trigger a mid-session change. +/// +public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposable +{ + private readonly Pipe _clientToServer = new(); + private readonly Pipe _serverToClient = new(); + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly ServiceProvider _services; + private readonly Task _serverTask; + private readonly StreamWriter _writer; + private readonly StreamReader _reader; + + public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection + .AddMcpServer() + .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) + .WithTools(); + + _services = serviceCollection.BuildServiceProvider(validateScopes: true); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); + + _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; + _reader = new StreamReader(_serverToClient.Reader.AsStream()); + } + + [Fact] + public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange() + { + var ct = TestContext.Current.CancellationToken; + + // The first request establishes the draft version for the stateful session (null -> draft). + Assert.IsType(await RoundTripAsync(id: 1, McpSession.DraftProtocolVersion, ct)); + + // Re-sending the same version is an idempotent no-op, not an error. + Assert.IsType(await RoundTripAsync(id: 2, McpSession.DraftProtocolVersion, ct)); + + // Switching to a different (still-supported) version mid-session is rejected. + var error = Assert.IsType(await RoundTripAsync(id: 3, McpSession.LatestProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); + + // The rejected request must not have mutated the negotiated version: the original draft version still works. + Assert.IsType(await RoundTripAsync(id: 4, McpSession.DraftProtocolVersion, ct)); + } + + private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + // tools/list is available under both the legacy and draft revisions (unlike ping/initialize, + // which the draft revision removed), so it exercises the version guard rather than the + // per-method availability gate. + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }, + }, + }; + + string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); +#if NET + await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); +#else + cancellationToken.ThrowIfCancellationRequested(); + await _writer.WriteLineAsync(json); +#endif + + while (true) + { +#if NET + string? line = await _reader.ReadLineAsync(cancellationToken) + .AsTask() + .WaitAsync(TestConstants.DefaultTimeout, cancellationToken); +#else + string? line = await _reader.ReadLineAsync() + .WaitAsync(TestConstants.DefaultTimeout, cancellationToken); +#endif + + if (line is null) + { + throw new InvalidOperationException("Server stream closed before responding."); + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var message = (JsonRpcMessage)JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)))!; + + // Ignore anything that isn't the response to the request we just sent (e.g. notifications). + if (message is JsonRpcMessageWithId withId && withId.Id.Equals(request.Id)) + { + return message; + } + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _clientToServer.Writer.Complete(); + _serverToClient.Writer.Complete(); + + try + { + await _serverTask; + } + catch (OperationCanceledException) + { + } + + await _services.DisposeAsync(); + _cts.Dispose(); + Dispose(); + } + + [McpServerToolType] + private sealed class EchoTools + { + [McpServerTool, Description("Echoes the input back to the caller.")] + public static string Echo([Description("The message to echo.")] string message) => message; + } +} 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..003077673 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -0,0 +1,188 @@ +#if !NET472 +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(_cts.Token).ConfigureAwait(false); + 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()); + } +} +#endif diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs new file mode 100644 index 000000000..a57c07d55 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an +/// in-memory stream transport (the stdio-shaped path exercised by ). +/// Validates that a draft client receives only the change notifications it subscribed to, each tagged +/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// +public class SubscriptionsListenTests : ClientServerTestBase +{ + public SubscriptionsListenTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools(); + mcpServerBuilder.WithPrompts(); + } + + [Fact] + public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed() + { + await using McpClient client = await CreateMcpClientForServer(); + + var ackChannel = Channel.CreateUnbounded(); + var toolsChannel = Channel.CreateUnbounded(); + var promptsChannel = Channel.CreateUnbounded(); + + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + await using var promptsReg = client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, + (notification, _) => { promptsChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync(client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token); + + // SEP-2575: the acknowledgement is always sent first, tagged with the subscription id. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverTools = serverOptions.ToolCollection!; + var serverPrompts = serverOptions.PromptCollection!; + + // A prompt change must never reach this client: it only subscribed to tool list changes. Because the + // fan-out skips it without sending anything, the prompts channel stays empty for the rest of the test. + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "AddedPrompt")] () => "added")); + + // A tool change must arrive on the subscription stream, tagged with the same subscription id as the ack. + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + var toolsNotification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(toolsNotification)); + + // Tear down the open subscription request before the client is disposed. + await CancelSubscriptionAsync(listenCts, listenTask); + + // The prompt change fired before the (delivered) tool change, and notifications arrive in order + // on the subscription stream, so any prompt notification would already be buffered by now. + // Complete the writer and assert the channel drains empty - i.e. nothing was ever delivered, + // not merely "nothing is buffered at this instant". WaitToReadAsync returns false only when the + // channel is both empty and completed; a buffered erroneous notification would make it true. + promptsChannel.Writer.Complete(); + Assert.False(await promptsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged() + { + await using McpClient client = await CreateMcpClientForServer(); + + var toolsChannel = Channel.CreateUnbounded(); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + // The change notification must not be broadcast to a draft client that never opened a + // subscriptions/listen stream. The list-changed handler runs synchronously during Add (before + // the ListTools round-trip below completes), so any erroneous broadcast would already be + // buffered once the round-trip returns. + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Complete the writer and assert the channel drains empty rather than just checking the current + // buffer: WaitToReadAsync returns false only when the channel is both empty and completed. + toolsChannel.Writer.Complete(); + Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + + var toolsChannel = Channel.CreateUnbounded(); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Null(GetSubscriptionId(notification)); + } + + private static Task SendSubscriptionsListenAsync(McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = notifications }, + McpJsonUtilities.DefaultOptions), + }; + + return client.SendRequestAsync(request, cancellationToken); + } + + private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask) + { + await listenCts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => listenTask); + } + + private static string? GetSubscriptionId(JsonRpcNotification notification) + => ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString(); + + [McpServerToolType] + private sealed class ListenTools + { + [McpServerTool, Description("Echoes the input back to the caller.")] + public static string Echo([Description("The message to echo.")] string message) => message; + } + + [McpServerPromptType] + private sealed class ListenPrompts + { + [McpServerPrompt, Description("A simple prompt.")] + public static ChatMessage Simple() => new(ChatRole.User, "hello"); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs new file mode 100644 index 000000000..97dba0fce --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs @@ -0,0 +1,189 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the SEP-2663 Tasks extension is gated to the draft protocol revision on both the +/// client and the server. Explicit task operations throw on a legacy session; best-effort task +/// augmentation silently downgrades to a direct result so that legacy peers never see a task. +/// +public class TaskDraftGatingTests : ClientServerTestBase +{ + private const string LatestStableVersion = "2025-11-25"; + + private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + private const string ExtensionsKey = "extensions"; + + public TaskDraftGatingTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; + }); + + mcpServerBuilder.WithTools([McpServerTool.Create( + async (string input, CancellationToken ct) => + { + await Task.Delay(50, ct); + return $"Processed: {input}"; + }, + new McpServerToolCreateOptions + { + Name = "test-tool", + Description = "A test tool" + })]); + } + + private static IDictionary CreateArguments(string key, string value) + { + return new Dictionary + { + [key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone() + }; + } + + private static JsonObject CreateForgedTaskOptInMeta() => + new() + { + [ClientCapabilitiesMetaKey] = new JsonObject + { + [ExtensionsKey] = new JsonObject + { + [McpExtensions.Tasks] = new JsonObject(), + }, + }, + }; + + [Fact] + public async Task LegacyClient_GetTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.GetTaskAsync("some-task-id", ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_UpdateTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.UpdateTaskAsync(new UpdateTaskRequestParams { TaskId = "some-task-id" }, ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_CancelTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.CancelTaskAsync("some-task-id", ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "legacy"), + }, ct); + + Assert.False(result.IsTask); + Assert.NotNull(result.Result); + } + + [Fact] + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy + // request. The server must still refuse to create a task because the per-request protocol + // version is not the draft revision. + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "forged"), + Meta = CreateForgedTaskOptInMeta(), + }, ct); + + Assert.False(result.IsTask); + Assert.NotNull(result.Result); + } + + [Fact] + public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + // Bypass the typed GetTaskAsync client guard by sending a raw tasks/get request. The server + // gates tasks/* to the draft revision and must reject this legacy request with MethodNotFound. + var request = new JsonRpcRequest + { + Method = RequestMethods.TasksGet, + Params = JsonSerializer.SerializeToNode( + new GetTaskRequestParams { TaskId = "some-task-id" }, + McpJsonUtilities.DefaultOptions), + }; + + var ex = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync(request, ct)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task DraftClient_CallToolRaw_CreatesTask() + { + // Sanity: the default client negotiates the draft revision, so the task flow still works. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "draft"), + }, ct); + + Assert.True(result.IsTask); + Assert.NotNull(result.TaskCreated); + } +}