From 4d200f4bb410c4912296e372bd3c96e2846a908a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:19:53 +0000 Subject: [PATCH 1/8] Require integrity protection for MRTR requestState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestState round-trips through the client, so the spec requires servers to treat the echo as attacker-controlled and reject verification failures whenever the state can influence authorization, resources, or business logic. Add a boundary middleware that seals every outgoing requestState into an authenticated-encrypted claims envelope (TTL, audience, principal, and originating-request binding) and verifies every echo before handlers run; handlers read and write plaintext on every surface. MCPServer requires a protection choice at construction whenever a registration can mint requestState — shared keys, an ephemeral process key, or an explicit opt-out that resolver tools refuse; the low-level tier opts in with one appended middleware. The built-in codec is AES-256-GCM under an HKDF-derived key ring with a key-id lookup, strict token canonicality, and a documented three-phase rotation. Every verification failure answers one frozen -32602 with the real reason in server logs only. The everything-server fixture drops its hand-rolled sealing and passes the tampered-state conformance scenario through the real code path. --- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 80 +- docs/migration.md | 18 + docs/tutorial/dependencies.md | 6 +- docs/tutorial/elicitation.md | 3 +- docs_src/dependencies/tutorial001.py | 4 +- docs_src/dependencies/tutorial002.py | 4 +- docs_src/dependencies/tutorial003.py | 4 +- docs_src/elicitation/tutorial004.py | 3 +- docs_src/mrtr/tutorial004.py | 4 +- docs_src/mrtr/tutorial005.py | 33 + .../mcp_everything_server/server.py | 45 +- examples/stories/README.md | 2 +- examples/stories/mrtr/README.md | 52 +- examples/stories/mrtr/client.py | 35 +- examples/stories/mrtr/server.py | 16 +- examples/stories/mrtr/server_lowlevel.py | 8 +- examples/stories/refund_desk/README.md | 5 +- examples/stories/refund_desk/server.py | 5 +- src/mcp-types/mcp_types/methods.py | 25 +- src/mcp/client/client.py | 6 +- src/mcp/server/mcpserver/__init__.py | 14 + src/mcp/server/mcpserver/server.py | 141 +- src/mcp/server/request_state.py | 559 ++++++++ src/mcp/server/runner.py | 2 +- tests/client/test_client.py | 12 +- tests/docs_src/test_mrtr.py | 23 +- tests/server/mcpserver/test_server.py | 43 +- tests/server/test_request_state.py | 494 +++++++ tests/server/test_request_state_boundary.py | 1193 +++++++++++++++++ tests/server/test_request_state_gate.py | 403 ++++++ tests/types/test_methods.py | 17 + 32 files changed, 3164 insertions(+), 97 deletions(-) create mode 100644 docs_src/mrtr/tutorial005.py create mode 100644 src/mcp/server/request_state.py create mode 100644 tests/server/test_request_state.py create mode 100644 tests/server/test_request_state_boundary.py create mode 100644 tests/server/test_request_state_gate.py diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 12c453294..a5fc1be71 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...])))` — one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 78e567e9d..5a80b19a8 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -35,11 +35,12 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT `tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context: -```python title="server.py" hl_lines="21 23 25" +```python title="server.py" hl_lines="6 21 23 25" --8<-- "docs_src/mrtr/tutorial004.py" ``` * The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. +* The `request_state_security=` argument is not optional: declaring an `InputRequiredResult` return means this server can mint a `requestState`, and `MCPServer` refuses to construct until you choose how to protect it. `ephemeral()` is the right answer for a single-process server like this one; **[Protecting `requestState`](#protecting-requeststate)** below covers what it does and the other choices. * An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. @@ -84,6 +85,82 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. * Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. +## Protecting `requestState` + +Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs — writing it down across processes is exactly what the previous section blessed — so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. + +This SDK is deliberately stricter than that conditional requirement: `MCPServer` refuses to construct at all while any registration can mint a `requestState` — a `Resolve(...)` parameter, or a tool, prompt, or resource-template function declaring an `InputRequiredResult` return — until you pass `request_state_security=`. The alternative is a server that runs fine in development and ships unprotected state the first time it matters. + +There are three choices: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) + +# Single process (stdio, one HTTP worker): a key generated at startup. +mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral()) + +# No protection. Read the caveats before reaching for this. +mcp = MCPServer("wizard", request_state_security=RequestStateSecurity.unprotected()) +``` + +* `keys=[...]` is the built-in encrypting codec under your secret(s). Required whenever a retry can reach a **different instance** — multi-worker or load-balanced HTTP — because every instance must be able to verify what any sibling minted. +* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over — right for a single process, wrong for a fleet. The tutorial servers in these docs all use it for that reason. +* `.unprotected()` sends state exactly as handlers wrote it and accepts whatever comes back. The spec permits this only when tampering can cause nothing worse than a failed request. `Resolve(...)` tools refuse this mode at registration: their state carries elicited answers, which are business inputs. + +### What the seal carries + +With either of the first two choices, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: + +* **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. +* **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK — a fronting proxy — or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. +* **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails. +* **The exact question asked.** A recorded resolver answer is pinned to the rendered question the client was shown. Redeploy with a reworded message or a changed schema and the server re-asks instead of reusing a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data — a message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. + +All of that is the SDK's job — not yours, and not the codec's if you bring your own. + +### Rotating keys + +`keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout. + +Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim by default, so a token minted by a different service that happens to share a secret is rejected anyway. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted. + +### Bring your own crypto + +`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS — unwrap a data key once at startup, then keep the per-token crypto local: + +```python title="server.py" hl_lines="12 29-30 33" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity — tampered means raise — and, ideally, confidentiality. + +### When verification fails + +Every inbound failure — tampered, expired, replayed against a different request or principal, sealed under a key this server doesn't know — gets the same answer: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. A server that never mints state at all — no MRTR registrations, no `request_state_security=` — rejects any inbound `requestState` the same way. + +### Hand-built state + +A `request_state` you set yourself — returning `InputRequiredResult` from a tool, prompt, or resource-template function — is sealed and verified by the same machinery: write plaintext, read plaintext. The one thing the SDK cannot pin for you is question identity, because it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. + +The low-level `Server` is the no-batteries tier: nothing is required at construction and nothing is sealed until you append the boundary yourself — one line, shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. + ## A 2026-07-28 result `InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. @@ -108,5 +185,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. +* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will mint one, and the seal binds every token to a time window, the originating request, and — when the request carries auth the SDK validated, or `bind_principal=` supplies your own identity signal — the authenticated client (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 047626ee2..553dbf83d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -423,6 +423,24 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. +### Servers that mint `requestState` must configure `request_state_security=` + +`requestState` round-trips through the client, so what comes back is client-supplied input. `MCPServer` now requires a protection choice at construction from any server that can mint one: registering a tool that uses `Resolve(...)` parameters, or a tool, prompt, or resource-template function that declares an `InputRequiredResult` return, raises `ValueError` until you pass `request_state_security=`. The one-line fix for a single-process server: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral()) +``` + +Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted, and `RequestStateSecurity.unprotected()` is the explicit opt-out for manual flows where tampering can cause nothing worse than a failed request (refused at registration for `Resolve(...)` tools). The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). + +Three behavior changes ride along: + +* On a protected server, `ctx.request_state` returns the verified plaintext your handler originally wrote, not the wire token — sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. +* A handler that returns an `InputRequiredResult` carrying `requestState` without having declared that return type — no annotation, or annotations the registration gate cannot resolve — on a server with no `request_state_security=` now answers `-32603` *"Internal error"* instead of shipping the state unprotected. The remediation goes to the server log: declare the return type, or configure `request_state_security=`. +* A server that never minted any state (no MRTR-capable registrations, no `request_state_security=`) now rejects any inbound `requestState` with `-32602` *"Invalid or expired requestState"* — the same frozen error every protected server answers when a token fails verification. + ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index b7b18fe76..f79fa636d 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -8,13 +8,14 @@ A tool's arguments come from the model. Some values never should: a price looked Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: -```python title="server.py" hl_lines="18-19 23" +```python title="server.py" hl_lines="8 18-19 23" --8<-- "docs_src/dependencies/tutorial001.py" ``` * `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument. * Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see. * The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble. +* `request_state_security=` is the one piece of ceremony. A tool with resolvers can pause mid-call to ask the user — that's later in this chapter — and resuming sends a token through the client, so the SDK makes you choose how that token is protected before it will build the server. `ephemeral()`, a key generated at process start, is the right choice for a single-process server like this one; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** has the full story. !!! info If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what @@ -131,7 +132,8 @@ That's the right default for a precondition: no answer, no order. When declining its question, an eliciting resolver must derive its question deterministically from the tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a timestamp) is re-derived on each round and must not appear in a question the answer is meant - to bind to. + to bind to. A question built from such volatile data makes every recorded answer look stale, + so the server re-asks it on every round until the client's round limit ends the call. ## Recap diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index 7bd27a78a..1a0c39cc5 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -85,13 +85,14 @@ The booking tool above weaves the question into its own body. When the question A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: -```python title="server.py" hl_lines="24-30 35-36" +```python title="server.py" hl_lines="16 25-31 36-37" --8<-- "docs_src/elicitation/tutorial004.py" ``` * `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. * `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. * The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. +* `request_state_security=` is new on this page's `MCPServer(...)`: on a 2026-07-28 connection the framework's question and its answer ride a resume token through the client, and a server with resolver tools must choose how that token is protected before it will construct. `ephemeral()` fits this single-process server; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** explains the choices. Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. diff --git a/docs_src/dependencies/tutorial001.py b/docs_src/dependencies/tutorial001.py index 182b54414..649271fb4 100644 --- a/docs_src/dependencies/tutorial001.py +++ b/docs_src/dependencies/tutorial001.py @@ -3,9 +3,9 @@ from pydantic import BaseModel from mcp.server import MCPServer -from mcp.server.mcpserver import Resolve +from mcp.server.mcpserver import RequestStateSecurity, Resolve -mcp = MCPServer("Bookshop") +mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/dependencies/tutorial002.py b/docs_src/dependencies/tutorial002.py index 3f24e2ceb..a46f223cd 100644 --- a/docs_src/dependencies/tutorial002.py +++ b/docs_src/dependencies/tutorial002.py @@ -3,9 +3,9 @@ from pydantic import BaseModel from mcp.server import MCPServer -from mcp.server.mcpserver import Resolve +from mcp.server.mcpserver import RequestStateSecurity, Resolve -mcp = MCPServer("Bookshop") +mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/dependencies/tutorial003.py b/docs_src/dependencies/tutorial003.py index 51252668e..37245877a 100644 --- a/docs_src/dependencies/tutorial003.py +++ b/docs_src/dependencies/tutorial003.py @@ -3,9 +3,9 @@ from pydantic import BaseModel, Field from mcp.server import MCPServer -from mcp.server.mcpserver import Elicit, Resolve +from mcp.server.mcpserver import Elicit, RequestStateSecurity, Resolve -mcp = MCPServer("Bookshop") +mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/elicitation/tutorial004.py b/docs_src/elicitation/tutorial004.py index 1edec06cf..3feacc248 100644 --- a/docs_src/elicitation/tutorial004.py +++ b/docs_src/elicitation/tutorial004.py @@ -9,10 +9,11 @@ DeclinedElicitation, Elicit, ElicitationResult, + RequestStateSecurity, Resolve, ) -mcp = MCPServer("Files") +mcp = MCPServer("Files", request_state_security=RequestStateSecurity.ephemeral()) _FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]} diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py index 05b945935..3a8679b05 100644 --- a/docs_src/mrtr/tutorial004.py +++ b/docs_src/mrtr/tutorial004.py @@ -1,9 +1,9 @@ from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.server.mcpserver.prompts.base import UserMessage -mcp = MCPServer("Briefing") +mcp = MCPServer("Briefing", request_state_security=RequestStateSecurity.ephemeral()) ASK_AUDIENCE = ElicitRequest( params=ElicitRequestFormParams( diff --git a/docs_src/mrtr/tutorial005.py b/docs_src/mrtr/tutorial005.py new file mode 100644 index 000000000..05155ae68 --- /dev/null +++ b/docs_src/mrtr/tutorial005.py @@ -0,0 +1,33 @@ +import os + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from mcp.server import MCPServer +from mcp.server.mcpserver import InvalidRequestState, RequestStateSecurity + +PREFIX = "kms1." # format version; fed to GCM as associated data, so it is bound under the tag + + +def unwrap_data_key() -> bytes: + """One KMS call at process start - kms.decrypt(CiphertextBlob=...) - then every token is local crypto.""" + return os.urandom(32) # stand-in for the unwrapped 32-byte data key + + +class EnvelopeCodec: + def __init__(self, data_key: bytes) -> None: + self._aesgcm = AESGCM(data_key) + + def seal(self, payload: bytes) -> str: + nonce = os.urandom(12) + return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex() + + def unseal(self, token: str) -> bytes: + try: + raw = bytes.fromhex(token.removeprefix(PREFIX)) + return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode()) + except (ValueError, InvalidTag) as exc: + raise InvalidRequestState("token failed verification") from exc + + +mcp = MCPServer("Deployer", request_state_security=RequestStateSecurity(codec=EnvelopeCodec(unwrap_data_key()))) diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 8621c877a..991f14d7e 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -6,16 +6,13 @@ import asyncio import base64 -import binascii -import hashlib -import hmac import json import logging from typing import Annotated, Any import click from mcp.server import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.server.mcpserver.prompts.base import UserMessage from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.shared.exceptions import MCPError @@ -47,7 +44,7 @@ TextResourceContents, UnsubscribeRequestParams, ) -from mcp_types.jsonrpc import INVALID_PARAMS, MISSING_REQUIRED_CLIENT_CAPABILITY +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -100,8 +97,13 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event # Create event store for SSE resumability (SEP-1699) event_store = InMemoryEventStore() +# Fixed key for the conformance fixture; a real deployment would load a shared secret. +# RequestStateSecurity requires keys of at least 32 bytes — this one is 43. +_REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key" + mcp = MCPServer( name="mcp-conformance-test-server", + request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), ) @@ -497,30 +499,14 @@ async def test_input_required_result_multi_round(ctx: Context) -> str | InputReq ) -# Fixed key for the conformance fixture; a real server would derive or rotate this. -_STATE_HMAC_KEY = b"everything-server-fixture-key" - - -def _seal_state(payload: str) -> str: - encoded = base64.urlsafe_b64encode(payload.encode()).decode() - sig = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() - return f"{encoded}.{sig}" - - -def _unseal_state(state: str) -> str: - encoded, _, sig = state.partition(".") - expected = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() - if not sig or not hmac.compare_digest(sig, expected): - raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") - try: - return base64.urlsafe_b64decode(encoded).decode() - except (binascii.Error, UnicodeDecodeError) as e: - raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") from e - - @mcp.tool() async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult: - """Tests that the server rejects a requestState that fails HMAC verification""" + """Tests that the server rejects a tampered requestState echo. + + The handler writes and reads plaintext state; sealing and tamper rejection + happen in the SDK's request-state boundary, so a tampered echo never + reaches this code. + """ if ctx.request_state is None: confirm = ElicitRequest( params=ElicitRequestFormParams( @@ -528,9 +514,8 @@ async def test_input_required_result_tampered_state(ctx: Context) -> str | Input requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, ) ) - return InputRequiredResult(input_requests={"confirm": confirm}, request_state=_seal_state("round-1")) - payload = _unseal_state(ctx.request_state) - return f"state-ok: {payload}" + return InputRequiredResult(input_requests={"confirm": confirm}, request_state="round-1") + return f"state-ok: {ctx.request_state}" @mcp.tool() diff --git a/examples/stories/README.md b/examples/stories/README.md index 8c1cceb5b..52facf1d9 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -128,7 +128,7 @@ opens with a banner saying what replaces it. | [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current | | **— feature stories —** | | | | [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | -| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and `RequestStateSecurity` sealing `requestState` (tamper → one frozen error) | current | | [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | | [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | | [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index aaad86ca9..34290ab65 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -6,7 +6,12 @@ input mid-call **returns** `resultType: "input_required"` with embedded server→client request. The client fulfils the embedded requests and retries the original `tools/call` carrying `inputResponses` and the echoed `requestState`. The story shows both the `Client` auto-loop (one `await call_tool`, callbacks -fired transparently) and a manual `client.session` loop (the persistable form). +fired transparently) and a manual `client.session` loop (the persistable form) +— and, because `requestState` round-trips through the client, the security +surface that protects it: the server is constructed with +`request_state_security=RequestStateSecurity.ephemeral()`, handlers keep +writing plaintext state, and the SDK seals it at the wire boundary. The manual +loop tampers with the sealed token to show what a forged echo gets back. ## Run it @@ -20,36 +25,55 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## What to look at +- `server.py` `build_server` — the whole security opt-in is one constructor + argument: `request_state_security=RequestStateSecurity.ephemeral()`. + `ephemeral()` generates a key at process start, which is right for a + single-process server like this one; a fleet (multi-worker or load-balanced) + shares keys with `RequestStateSecurity(keys=[...])` so any instance can + verify state another minted. +- `server.py` `deploy` — handlers stay plaintext: the first round returns + `InputRequiredResult(input_requests={...}, + request_state="awaiting-confirm")` and the retry asserts + `ctx.request_state == "awaiting-confirm"`. The tool never touches the + crypto; the boundary seals on the way out and unseals the echo on the way + back in. - `client.py` `main` — the auto-loop is invisible at the call site: `Client(target, mode=mode, elicitation_callback=on_elicit)` then `await client.call_tool("deploy", ...)`. The same `on_elicit` callback the legacy push path uses is dispatched for each embedded `inputRequests` entry. - `client.py` manual block — `client.session.call_tool(..., allow_input_required=True)` returns the raw `InputRequiredResult` so - `request_state` can be persisted between rounds; the retry is just another - `tools/call` with `input_responses=` / `request_state=`. -- `server.py` `deploy` — `ctx.input_responses` / `ctx.request_state` read the - retry payload; the first round returns - `InputRequiredResult(input_requests={...}, request_state=...)`, the second - returns the final string. -- `server_lowlevel.py` — same wire contract via `params.input_responses` / - `params.request_state` and a hand-built `InputRequiredResult`. + `request_state` can be persisted between rounds. The wire value is an opaque + sealed token, **not** the string the server code wrote — the client asserts + exactly that, then retries with one character of the token flipped and gets + the single frozen error every verification failure maps to: `-32602`, + `"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`. + The specific reason (tampered tag, expiry, wrong request, wrong principal) + appears only in the server's log, never on the wire. The untampered token + then completes the round normally. +- `server_lowlevel.py` — the lowlevel tier has no construction-time + requirement; the same enforcement is one appended middleware: + `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral()))`. ## Caveats - **Loop bound.** The auto-loop gives up after `input_required_max_rounds` (default 10) with `InputRequiredRoundsExceededError`; raise it on the `Client` ctor or drop to the manual loop. -- **`requestState` integrity is the server's job.** The client echoes it - byte-exact and never inspects it; the server MUST treat it as - attacker-controlled. The SDK ships no signing helper yet. +- **`ephemeral()` dies with the process.** The key is generated at startup and + held only in memory, so a server restart (or a retry landing on a different + instance) invalidates in-flight rounds: the client gets the same frozen + rejection and must start the flow over. Use + `RequestStateSecurity(keys=[...])` when state must survive either. ## Spec -[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results) +[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results), +[Multi-round-trip requests — security patterns](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr) ## See also `legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the -MCPServer tier: the questions a tool can declare instead of pushing by hand. +MCPServer tier: the questions a tool can declare instead of pushing by hand +(its elicited answers ride in the same sealed `requestState`). diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py index 5b686c3c9..62d10dc92 100644 --- a/examples/stories/mrtr/client.py +++ b/examples/stories/mrtr/client.py @@ -2,6 +2,7 @@ import mcp_types as types +from mcp import MCPError from mcp.client import Client, ClientRequestContext from stories._harness import Target, run_client @@ -27,14 +28,42 @@ async def main(target: Target, *, mode: str = "auto") -> None: first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True) assert isinstance(first, types.InputRequiredResult) assert first.input_requests is not None and "confirm" in first.input_requests - assert first.request_state == "awaiting-confirm" - # Decline this time so the path diverges from the auto-loop run above. + # The wire request_state is OPAQUE: server.py wrote "awaiting-confirm", but the + # boundary middleware sealed it before it left the server — the plaintext never + # crosses the wire, and the client just echoes the token byte-exact. + token = first.request_state + assert token is not None and token != "awaiting-confirm", token + responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")} + + # Tamper demonstration: flip one character and retry. The token decodes strictly + # canonically, so changing ANY character — including the final one — rejects. + # Every verification failure collapses to ONE frozen wire error; the real reason + # (here: a failed authentication tag) appears only in the server's log. + i = len(token) // 2 + tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] + try: + await client.session.call_tool( + "deploy", + {"env": "staging"}, + input_responses=responses, + request_state=tampered, + allow_input_required=True, + ) + except MCPError as e: + assert e.code == types.INVALID_PARAMS + assert e.message == "Invalid or expired requestState" + assert e.data == {"reason": "invalid_request_state"} + else: + raise AssertionError("expected MCPError for a tampered requestState") + + # The untampered token still completes the round. Decline this time so the path + # diverges from the auto-loop run above. second = await client.session.call_tool( "deploy", {"env": "staging"}, input_responses=responses, - request_state=first.request_state, + request_state=token, allow_input_required=True, ) assert isinstance(second, types.CallToolResult) diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py index d83c2e983..a8850bc7d 100644 --- a/examples/stories/mrtr/server.py +++ b/examples/stories/mrtr/server.py @@ -2,7 +2,7 @@ from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from stories._hosting import run_server_from_args CONFIRM_SCHEMA: ElicitRequestedSchema = { @@ -13,19 +13,25 @@ def build_server() -> MCPServer: - mcp = MCPServer("mrtr-example") + # requestState round-trips through the client, so the SDK requires a protection + # policy before it lets a tool mint one. ephemeral() = a key generated at process + # start; right for single-process servers like this one. Fleets share keys=[...]. + mcp = MCPServer("mrtr-example", request_state_security=RequestStateSecurity.ephemeral()) @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: responses = ctx.input_responses if responses is None or "confirm" not in responses: - # First round: ask the client to elicit confirmation. request_state is opaque - # to the client; here it carries the step name so the retry can verify the echo. + # First round: ask the client to elicit confirmation. The handler writes its + # request_state in PLAINTEXT — the boundary middleware seals it into an opaque + # token on the way out and unseals the echo on the retry, so this code never + # touches the crypto. (client.py proves the wire never carries this string.) ask = ElicitRequest( params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) ) return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") - # Retry round: the client echoed request_state byte-exact and supplied the answer. + # Retry round: the client echoed the sealed token byte-exact; the boundary + # verified it and handed back the plaintext this handler originally wrote. assert ctx.request_state == "awaiting-confirm", ctx.request_state answer = responses["confirm"] if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"): diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py index 0ed13cea4..c71a10bf6 100644 --- a/examples/stories/mrtr/server_lowlevel.py +++ b/examples/stories/mrtr/server_lowlevel.py @@ -6,6 +6,7 @@ from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from stories._hosting import run_server_from_args CONFIRM_SCHEMA: types.ElicitRequestedSchema = { @@ -55,7 +56,12 @@ async def call_tool( return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")]) return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) - return Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + # The lowlevel tier has no construction-time requirement; appending the boundary + # middleware is the whole opt-in, and it is the identical enforcement MCPServer + # installs from its request_state_security= parameter. + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral())) + return server if __name__ == "__main__": diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 5b5bb5532..1cfb717bd 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -29,7 +29,10 @@ uv run python -m stories.refund_desk.client --http - `server.py` `refund_order` — the signature is the whole story: `order_id` and `reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers and never reach the input schema. `client.py` asserts `properties` and - `required` are exactly `{order_id, reason}`. + `required` are exactly `{order_id, reason}`. The server is constructed with + `request_state_security=RequestStateSecurity.ephemeral()` because at 2026 the + resolver's elicited answers ride between rounds inside a sealed + `requestState` — see `mrtr/` for the full security walk-through. - `server.py` `refund_scope` — the no-round-trip fast path: a one-line order returns `Scope(full=True)` directly; only a multi-line order returns `Elicit(...)`. The ORD-7001 call completes with zero elicitations. diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py index f29a266f0..0cd8ad2cd 100644 --- a/examples/stories/refund_desk/server.py +++ b/examples/stories/refund_desk/server.py @@ -11,6 +11,7 @@ Elicit, ElicitationResult, MCPServer, + RequestStateSecurity, Resolve, ) from mcp.server.mcpserver.exceptions import ToolError @@ -103,7 +104,9 @@ def ask_restock( def build_server() -> MCPServer: - mcp = MCPServer("refund-desk") + # At 2026 the elicited answers ride between rounds inside requestState; resolver + # tools refuse to register without protection. See mrtr/ for the full story. + mcp = MCPServer("refund-desk", request_state_security=RequestStateSecurity.ephemeral()) @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") def refund_order( diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index f49c158d9..11bdb9b4b 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from functools import cache from types import MappingProxyType, UnionType -from typing import Any, Final, Literal, TypeVar, get_args +from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args from pydantic import BaseModel, TypeAdapter @@ -28,6 +28,7 @@ "CLIENT_REQUESTS", "CLIENT_RESULTS", "CacheableMethod", + "INPUT_REQUIRED_METHODS", "MONOLITH_NOTIFICATIONS", "MONOLITH_REQUESTS", "MONOLITH_RESULTS", @@ -36,6 +37,7 @@ "SERVER_RESULTS", "SPEC_CLIENT_METHODS", "SPEC_CLIENT_NOTIFICATION_METHODS", + "is_input_required", "parse_client_notification", "parse_client_request", "parse_client_result", @@ -423,6 +425,27 @@ ) """Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`.""" +INPUT_REQUIRED_METHODS: Final[frozenset[str]] = frozenset( + method + for method, row in MONOLITH_RESULTS.items() + if any( + issubclass(arm, types.InputRequiredResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)) + ) +) +"""Methods whose results may be `InputRequiredResult` (the MRTR carriers), derived from `MONOLITH_RESULTS`.""" + + +def is_input_required(result: object) -> TypeGuard[types.InputRequiredResult | dict[str, Any]]: + """True when `result` is an `input_required` interim result, typed or wire-shaped. + + Covers both shapes a server result takes in-process: the `InputRequiredResult` + model, and the serialized wire dict discriminated by `resultType` (any mapping + matches at runtime; the guard claims the SDK's wire-dict shape). + """ + if isinstance(result, types.InputRequiredResult): + return True + return isinstance(result, Mapping) and cast("Mapping[str, Any]", result).get("resultType") == "input_required" + # --- Parse functions --- diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 638ea63a9..6a5f67b2e 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -609,7 +609,11 @@ async def call_tool( callbacks and the call is retried automatically (up to `input_required_max_rounds`). To drive the loop yourself — e.g. to persist `request_state` across process restarts — use - `client.session.call_tool(..., allow_input_required=True)`. + `client.session.call_tool(..., allow_input_required=True)`. Persisted + state resumes only within the server's constraints: the token expires + after the server's per-round TTL (default 10 minutes), is bound to the + exact original request, and dies with the server's key — an + `ephemeral()` server rejects it after a restart. Args: name: The name of the tool to call. diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 8ee6c4e4e..0205df192 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -3,6 +3,14 @@ from mcp_types import Icon from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateBoundary, + RequestStateCodec, + RequestStateSecurity, + authenticated_principal, +) from .context import Context from .resolve import ( @@ -36,4 +44,10 @@ "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", + "RequestStateSecurity", + "RequestStateCodec", + "RequestStateBoundary", + "AESGCMRequestStateCodec", + "InvalidRequestState", + "authenticated_principal", ] diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 676470980..e49d18088 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import enum import inspect from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager @@ -73,6 +74,7 @@ from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError from mcp.server.mcpserver.prompts import Prompt, PromptManager +from mcp.server.mcpserver.resolve import find_resolved_parameters, returns_input_required from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, FunctionResource, @@ -83,6 +85,7 @@ from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore @@ -145,6 +148,74 @@ async def wrap(_: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]: return wrap +class _MrtrCapability(enum.Enum): + """Why a registration can mint `requestState` (a multi-round-trip carrier).""" + + RESOLVER = "uses Resolve(...) parameters" + DECLARED_MANUAL = "declares an InputRequiredResult return" + + +def _mrtr_capability(fn: Callable[..., Any]) -> _MrtrCapability | None: + """Why this tool function can mint `requestState`, or None if it can't. + + A function declaring both capabilities is an invalid registration that + `Tool.from_function` rejects with its own error (one call has one + input_required channel), so the gate stands aside for that combination; + for every valid registration at most one capability applies. + """ + resolved = find_resolved_parameters(fn) + declared = returns_input_required(fn) + if resolved and declared: + return None + if resolved: + return _MrtrCapability.RESOLVER + if declared: + return _MrtrCapability.DECLARED_MANUAL + return None + + +def _format_missing_security(owner: str, capability: _MrtrCapability, *, opted_out: bool) -> str: + """The teaching error for an MRTR-capable registration with no usable protection. + + `opted_out` selects the closing block: an unconfigured server is shown the + `unprotected()` escape hatch; a server that already chose `unprotected()` is + told why a resolver tool refuses it. + """ + if opted_out: + closing = ( + " Resolve(...) tools cannot opt out: their requestState carries elicited\n" + " answers, which are business inputs. Use keys=[...] or .ephemeral()." + ) + else: + closing = ( + " MCPServer(..., request_state_security=RequestStateSecurity.unprotected())\n" + " No protection. Only valid when tampering can cause nothing worse than a\n" + " failed request - not available for Resolve(...) tools, whose state\n" + " carries elicited answers." + ) + return ( + f"{owner} {capability.value}, so this server mints a\n" + "requestState that round-trips through the client. The MCP spec requires that state\n" + "to be integrity-protected, and rejected when verification fails, whenever it can\n" + "influence authorization, resource access, or business logic. Configure protection:\n" + "\n" + " MCPServer(..., request_state_security=RequestStateSecurity(keys=[key]))\n" + " One or more shared secret keys (>= 32 bytes each). Required when a retry\n" + " can reach a different instance (multi-worker or load-balanced HTTP).\n" + " keys[0] seals, every key verifies; rotation is\n" + " [old, new] -> [new, old] -> [new], each phase fully rolled out first.\n" + "\n" + " MCPServer(..., request_state_security=RequestStateSecurity.ephemeral())\n" + " A key generated at process start. Single-process deployments only\n" + " (stdio, one HTTP worker): state minted before a restart, or by another\n" + " instance, is rejected and the client must restart the flow.\n" + "\n" + f"{closing}\n" + "\n" + "Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr" + ) + + class MCPServer(Generic[LifespanResultT]): def __init__( self, @@ -170,9 +241,11 @@ def __init__( lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, ): self._resource_security = resource_security + self._request_state_security = request_state_security self.settings = Settings( debug=debug, log_level=log_level, @@ -210,6 +283,22 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) + # The boundary owns `requestState` at the wire in both directions. It is + # installed unconditionally so an unconfigured server fails safe (inbound + # state is rejected, an outbound emission is an internal error - never + # silent plaintext), and it is appended here - after the lowlevel server + # is built and before `_install_extension_interceptor` - so it sits + # inside OpenTelemetry (spans record the sealed wire truth) and outside + # extension interceptors (extensions see plaintext). The server name is + # the default audience, so services sharing a key reject each other's + # state unless the policy names its own audience. + self._lowlevel_server.middleware.append( + RequestStateBoundary(request_state_security, default_audience=self.name) + ) + # Constructor-supplied Tool objects bypass `add_tool` (ToolManager + # inserts them directly), so gate them here, before any client connects. + for tool in self._tool_manager.list_tools(): + self._check_mrtr_protection(tool, owner=f"Tool {tool.name!r}") # Validate auth configuration if self.settings.auth is not None: if auth_server_provider and token_verifier: # pragma: no cover @@ -526,6 +615,37 @@ async def read_resource( # If an exception happens when reading the resource, we should not leak the exception to the client. raise ResourceError(f"Error reading resource {uri}") from exc + def _check_mrtr_protection(self, subject: Tool | Callable[..., Any], *, owner: str) -> None: + """Refuse an MRTR-capable registration when the server has no request-state posture. + + The single gate for every registration funnel; it derives the MRTR + capability itself. A `Tool` is judged by its stored authority - + `resolved_params` decides RESOLVER directly, with no combo-deferral, + because a hand-built Tool has no `from_function` validation to defer + to. A bare callable (the `add_tool`/`add_prompt`/`resource` funnels) is + judged by signature inspection, where `Tool.from_function`'s own combo + rejection takes precedence. + + Runs before the registration reaches its manager, so a rejected + registration leaves no trace and the server stays usable. Raises the + teaching ValueError when the capability is set and the server has no + `request_state_security=` (or only `unprotected()`, which resolver + tools refuse - their state carries elicited answers). + """ + if isinstance(subject, Tool): + if subject.resolved_params: + capability = _MrtrCapability.RESOLVER + else: + capability = _MrtrCapability.DECLARED_MANUAL if returns_input_required(subject.fn) else None + else: + capability = _mrtr_capability(subject) + if capability is None: + return + security = self._request_state_security + if security is not None and not (security.is_unprotected and capability is _MrtrCapability.RESOLVER): + return + raise ValueError(_format_missing_security(owner, capability, opted_out=security is not None)) + def add_tool( self, fn: Callable[..., Any], @@ -554,7 +674,14 @@ def add_tool( - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool + + Raises: + ValueError: If the tool can mint `requestState` (it uses + `Resolve(...)` parameters or declares an `InputRequiredResult` + return) and the server was constructed without a usable + `request_state_security=`. """ + self._check_mrtr_protection(fn, owner=f"Tool {name or fn.__name__!r}") self._tool_manager.add_tool( fn, name=name, @@ -753,9 +880,11 @@ async def get_weather(city: str) -> str: Raises: InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template. ValueError: If URI template parameters don't match the - function's parameters, or if a parameter bound to a + function's parameters, if a parameter bound to a ``{?...}``/``{&...}`` query variable has no default - (the client may omit it). + (the client may omit it), or if a template function declares + an `InputRequiredResult` return on a server constructed + without `request_state_security=`. TypeError: If the decorator is applied without being called (``@resource`` instead of ``@resource("uri")``). """ @@ -804,6 +933,8 @@ def decorator(fn: _CallableT) -> _CallableT: f"default." ) + self._check_mrtr_protection(fn, owner=f"Resource template {uri!r}") + # Register as template self._resource_manager.add_template( fn=fn, @@ -854,7 +985,13 @@ def add_prompt(self, prompt: Prompt) -> None: Args: prompt: A Prompt instance to add + + Raises: + ValueError: If the prompt function declares an + `InputRequiredResult` return and the server was constructed + without `request_state_security=`. """ + self._check_mrtr_protection(prompt.fn, owner=f"Prompt {prompt.name!r}") self._prompt_manager.add_prompt(prompt) def prompt( diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py new file mode 100644 index 000000000..e1e0aad7f --- /dev/null +++ b/src/mcp/server/request_state.py @@ -0,0 +1,559 @@ +"""Integrity protection for the multi-round-trip `requestState` (MCP 2026-07-28). + +`requestState` round-trips through the client, so the spec requires servers to +treat the echoed value as attacker-controlled, integrity-protect any state that +influences authorization, resource access, or business logic, and reject state +that fails verification (basic/patterns/mrtr, server requirements 4-5). + +This module is the composable tier: `RequestStateBoundary` is a server middleware +that seals every outgoing `requestState` and unseals (verifies) every inbound +echo, so handlers and resolvers only ever see the plaintext state they minted. +`MCPServer` installs it automatically from its `request_state_security=` +parameter; lowlevel `Server` users append it to `Server.middleware` themselves. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import math +import os +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import replace +from typing import Any, NoReturn, Protocol, cast + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from mcp_types import INTERNAL_ERROR, INVALID_PARAMS +from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.shared.exceptions import MCPError + +__all__ = [ + "AESGCMRequestStateCodec", + "InvalidRequestState", + "RequestStateBoundary", + "RequestStateCodec", + "RequestStateSecurity", + "authenticated_principal", +] + +logger = logging.getLogger(__name__) + + +class InvalidRequestState(Exception): + """A sealed `requestState` token failed verification. + + Raised by `RequestStateCodec.unseal` implementations for any failure — + malformed token, failed authentication, unknown key. The message is a short + reason code for server logs only; the boundary never puts it on the wire. + + (Deliberately not named `InvalidSignature`: that name already exists in + `mcp.server.mcpserver.exceptions` and means a bad Python callable signature.) + """ + + +class RequestStateCodec(Protocol): + """Seals the framework's request-state envelope for its trip through the client. + + Implementations do authenticated crypto over opaque bytes and NOTHING else. + The framework owns the envelope: it stamps mint time, expiry, the originating + request's method/target/argument digest, and the principal tag into the + payload before `seal`, and re-verifies every one of them after `unseal`. A + codec therefore cannot get TTL, replay-binding, or principal-binding wrong — + its only obligations are integrity (tamper -> raise) and, ideally, + confidentiality. + + Requirements: + - `unseal(seal(payload))` round-trips `payload`; `unseal` MUST raise + `InvalidRequestState` for any token it did not mint, or that was + modified in any way. + - The token MUST NOT name its algorithm; version it with a format prefix + bound under the authentication tag (RFC 8725 discipline). + - Comparisons MUST be constant-time (an AEAD primitive satisfies this). + - Prefer an encrypting construction: the payload carries server state and + a salted principal digest; a sign-only codec makes both client-readable. + - Both methods are synchronous; cache key material locally (envelope + encryption) rather than calling a KMS per token — see the docs example. + """ + + def seal(self, payload: bytes) -> str: + """Return an opaque URL-safe token protecting `payload`.""" + ... + + def unseal(self, token: str) -> bytes: + """Reverse `seal`. + + Raises: + InvalidRequestState: If the token is malformed, fails + authentication, or was sealed under an unknown key. + """ + ... + + +def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: + """Default principal binding: the authenticated OAuth client, when present. + + Reads the access token that `AuthContextMiddleware` stored for this request + and returns its `client_id`. Returns `None` on unauthenticated transports + (stdio, auth-less HTTP), in which case state is not principal-bound. + Replace via `RequestStateSecurity(bind_principal=...)` to bind to a richer + identity (e.g. an end-user subject from your own auth layer). + """ + token = get_access_token() + return token.client_id if token is not None else None + + +class RequestStateSecurity: + """Policy for protecting `requestState`: which codec, what TTL, which principal. + + Exactly one of `keys` or `codec`: + + RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM, shared key(s) + RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto + RequestStateSecurity.ephemeral() # process-local key; single process only + RequestStateSecurity.unprotected() # explicit opt-out (read its docstring) + + `keys` is the rotation ring: `keys[0]` seals new state; every key may + unseal. Zero-downtime rotation is three phases (each fully rolled out + before the next): `keys=[old, new]` (every instance learns to verify the + new key; old still mints) -> `keys=[new, old]` (new mints; in-flight old + state keeps verifying) -> after one TTL, `keys=[new]`. + + The sealed envelope carries mint time, a short expiry, the originating + request's method + target + argument digest, and a salted digest of + `bind_principal(ctx)` — the spec's three recommended replay bounds, on by + default and enforced by the boundary for EVERY codec, including custom + ones. Principal binding applies when the SDK authenticates the request (the + default binding derives no principal on unauthenticated transports) and is + fail-closed in both directions: state sealed with a principal is rejected + by a verifier that derives none, and vice versa. + + `audience` distinguishes services that share — or accidentally reuse — a + secret: it is stamped into the envelope and verified fail-closed in both + directions. `None` leaves state audience-unbound unless the server tier + supplies a default (`MCPServer` passes its server name as the boundary's + `default_audience`). + """ + + codec: RequestStateCodec | None + ttl: float + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None + audience: str | None + + def __init__( + self, + *, + keys: Sequence[bytes | bytearray | str] | None = None, + codec: RequestStateCodec | None = None, + ttl: float = 600.0, + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None = authenticated_principal, + audience: str | None = None, + _unprotected: bool = False, + ) -> None: + if _unprotected: + # `unprotected()`'s spelling: no codec, no binding, no audience; `ttl` is never read. + self.codec = None + self.ttl = ttl + self.bind_principal = None + self.audience = None + return + if (keys is None) == (codec is None): + raise ValueError("RequestStateSecurity takes exactly one of keys= or codec=") + if not (math.isfinite(ttl) and ttl > 0): + raise ValueError(f"request-state ttl must be a positive finite number, got {ttl!r}") + self.codec = AESGCMRequestStateCodec(keys) if keys is not None else codec + self.ttl = ttl + self.bind_principal = bind_principal + self.audience = audience + + @classmethod + def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity: + """Protection under a key generated now and held only by this process. + + Valid for single-process deployments (stdio, a single HTTP worker): the + one process that mints state is the one that receives the retry. It + FAILS across instances and restarts — state minted before a restart, or + by another worker behind a load balancer, is rejected with the standard + "Invalid or expired requestState" error and the client must start the + flow over. Multi-instance deployments must share a key: + `RequestStateSecurity(keys=[...])`. `ttl` and `audience` carry the same + meaning as on the main constructor. + """ + return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) + + @classmethod + def unprotected(cls) -> RequestStateSecurity: + """No protection: `requestState` crosses the wire exactly as handlers wrote it. + + The spec permits this ONLY "when tampering can cause nothing worse than + request failure" (basic/patterns/mrtr). A client can then read, forge, + and replay your state at will — never put data that influences + authorization, resource access, or business logic in it. A server + configured this way fails the `input-required-result-tampered-state` + conformance scenario by design. Resolver-driven tools + (`Resolve(...)` parameters) refuse this mode at registration: their + state carries elicited answers, which are business inputs. + """ + return cls(_unprotected=True) + + @property + def is_unprotected(self) -> bool: + return self.codec is None + + +_KDF_INFO = b"mcp/request-state/v1/aes-256-gcm" +_KID_INFO = b"mcp/request-state/v1/kid:" +_TOKEN_PREFIX = "v1." +_KID_LEN = 4 +_NONCE_LEN = 12 + + +def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64u_decode(text: str) -> bytes: + """Strict inverse of `_b64u`: only the canonical unpadded encoding decodes. + + The round-trip check rejects every malleable variant a lax decoder admits — + non-zero trailing don't-care bits, injected non-alphabet characters, and + appended padding — raising ValueError for all of them. + """ + raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + if _b64u(raw) != text: + raise ValueError("non-canonical base64url") + return raw + + +def _derive_key(secret: bytes) -> bytes: + """Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key.""" + return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret) + + +class AESGCMRequestStateCodec: + """Built-in codec: AES-256-GCM under key(s) derived with HKDF-SHA256. + + The token is opaque: contents are encrypted, not merely signed, so clients + (and anything that logs the wire) cannot read resolver keys, elicited + answers, or whatever a manual flow put in its state. `keys[0]` seals; all + keys unseal (rotation, see `RequestStateSecurity`). Each token carries a + 4-byte non-secret fingerprint of its key, so verification is an O(1) ring + lookup — never trial decryption. Key bytes are copied at construction, so + later mutation of a caller-held bytearray has no effect. + + The "v1." prefix and the key fingerprint are fed into the GCM associated + data, so both are bound under the authentication tag: a v1 token cannot be + replayed into a future "v2." format, nor transplanted across ring slots + (RFC 8725 discipline — the token never names an algorithm; the version + prefix pins the whole construction server-side). Authentication failure is + constant-time inside the AEAD primitive, and every failure raises + `InvalidRequestState` with a log-only reason code. + """ + + def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: + for i, key in enumerate(cast("Sequence[object]", keys)): + if not isinstance(key, bytes | bytearray | str): + # Never coerce: bytes(32) would silently build an all-zero key. + raise TypeError( + f"request-state keys must be bytes, bytearray, or str; keys[{i}] is {type(key).__name__}" + ) + material = [k.encode() if isinstance(k, str) else bytes(k) for k in keys] + if not material: + raise ValueError("AESGCMRequestStateCodec requires at least one key") + for i, k in enumerate(material): + if len(k) < 32: + raise ValueError( + f"request-state keys must be at least 32 bytes of secret randomness; " + f"keys[{i}] is {len(k)} bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + self._ring: dict[bytes, AESGCM] = {} + self._mint_kid = b"" + for i, secret in enumerate(material): + key = _derive_key(secret) + kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN] + if kid in self._ring: + raise ValueError(f"keys[{i}] duplicates an earlier ring key") + self._ring[kid] = AESGCM(key) + if i == 0: + self._mint_kid = kid + + def seal(self, payload: bytes) -> str: + kid = self._mint_kid + nonce = os.urandom(_NONCE_LEN) + sealed = self._ring[kid].encrypt(nonce, payload, _TOKEN_PREFIX.encode() + kid) + return _TOKEN_PREFIX + _b64u(kid + nonce + sealed) + + def unseal(self, token: str) -> bytes: + if not token.startswith(_TOKEN_PREFIX): + raise InvalidRequestState("malformed") + try: + raw = _b64u_decode(token[len(_TOKEN_PREFIX) :]) + except ValueError as exc: + raise InvalidRequestState("malformed") from exc + if len(raw) < _KID_LEN + _NONCE_LEN + 16: + raise InvalidRequestState("malformed") + kid, nonce, sealed = raw[:_KID_LEN], raw[_KID_LEN : _KID_LEN + _NONCE_LEN], raw[_KID_LEN + _NONCE_LEN :] + aead = self._ring.get(kid) + if aead is None: + raise InvalidRequestState("unknown key") + try: + return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid) + except InvalidTag: + raise InvalidRequestState("seal") from None + + +# The multi-round-trip carriers — the only methods whose results may carry +# `requestState`. Single source: the monolith result map in `mcp_types.methods`. +_MRTR_METHODS = INPUT_REQUIRED_METHODS +_ENVELOPE_VERSION = 1 +_FUTURE_SKEW = 60.0 +_PRINCIPAL_LABEL = b"mcp/request-state/principal:" + +_RoundBinding = tuple[str, str, str | None] +"""(target, args-digest, principal) one round's envelope binds — computed once per round.""" + + +def _reject(method: str, reason: str) -> NoReturn: + """Refuse a round: frozen wire error, real reason to the server log only.""" + logger.warning("requestState rejected on %s: %s", method, reason) + raise MCPError( + code=INVALID_PARAMS, + message="Invalid or expired requestState", + data={"reason": "invalid_request_state"}, + ) + + +def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[str, str]: + """Salient (target, args-digest) for the request a token binds to. + + Explicit per-method allowlist (never a denylist): tools/call and + prompts/get bind name + arguments; resources/read binds the uri. Retry-only + fields (inputResponses, requestState, _meta) are structurally excluded, and + a future wire field cannot silently join the digest. + """ + p: Mapping[str, Any] = params or {} + args: dict[str, Any] = {} + if method == "resources/read": + target = str(p.get("uri", "")) + else: + target, args = str(p.get("name", "")), p.get("arguments") or args + canonical = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return target, _b64u(hashlib.sha256(canonical.encode()).digest()[:16]) + + +def _principal_claim(principal: str) -> str: + salt = os.urandom(8) + tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + principal.encode()).digest()[:16] + return _b64u(salt + tag) + + +def _principal_matches(claim: str, principal: str) -> bool: + try: + raw = _b64u_decode(claim) + except ValueError: + return False + # A wrong-length claim cannot match: the recomputed 16-byte tag never + # equals a differently-sized remainder (compare_digest handles the sizes). + expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + principal.encode()).digest()[:16] + return hmac.compare_digest(raw[8:], expected) + + +class RequestStateBoundary: + """Server middleware sealing/unsealing `requestState` at the wire boundary. + + Inbound: a request presenting `requestState` (any non-null value, on any + method) is handled before any extension interceptor or handler runs. On the + multi-round-trip carriers (tools/call, prompts/get, resources/read) the + value is verified (codec unseal + claims check: version, mint-time skew, + expiry, method, target, argument digest, audience, principal) and replaced + with the plaintext the server originally minted. Every other method has no + legal carrier for the field, so the request is rejected outright. + Verification failure answers a wire-level -32602 with the frozen message + "Invalid or expired requestState"; the underlying reason goes to the server + log only. + + Outbound: an `input_required` result carrying `requestState` on a + multi-round-trip carrier has it sealed inside a fresh claims envelope; on + any other method an emission is a server bug answered as an internal error, + never silent plaintext. Handlers and resolvers write plaintext and never + call the codec themselves. + + `default_audience` seeds the envelope's audience claim when the policy does + not set its own `audience`. `MCPServer` passes its server name, so two + services sharing (or accidentally reusing) a key reject each other's state + by default. + + `ctx.params` is the raw, unvalidated wire mapping (no model validation has + happened yet), so the field is the camelCase wire key "requestState"; the + inbound rewrite replaces that key on a copy of the params and forwards it + with `dataclasses.replace(ctx, params=...)` — the rewrite contract + `ServerMiddleware` sanctions. + + `MCPServer` installs this automatically from `request_state_security=`. + Lowlevel `Server` users append one to `server.middleware` — they get the + identical claims enforcement; nothing is private to MCPServer. + + With `security=None` (an `MCPServer` that has no MRTR registrations and no + configuration) the boundary fails safe at runtime: inbound `requestState` + is rejected — this server never minted one — and an outbound emission is a + server bug answered as an internal error, never silent plaintext. Declared + MRTR surfaces never reach that branch — registration already failed at + construction (see the startup gate) — while statically-undetectable cases + (unannotated returns, TYPE_CHECKING-only annotations, wrapped functions) + land on the loud runtime error instead. + """ + + def __init__(self, security: RequestStateSecurity | None, *, default_audience: str | None = None) -> None: + self._security = security + self._audience = ( + security.audience if security is not None and security.audience is not None else default_audience + ) + + async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + binding: _RoundBinding | None = None + if ctx.params is not None and ctx.params.get("requestState") is not None: + # An explicit JSON null is the field's absence (a fresh flow): only + # presented state is verified, and stripping the field is already + # in any client's power. + if ctx.method not in _MRTR_METHODS: + _reject(ctx.method, "requestState on a non-MRTR method") + plaintext, binding = self._unseal(ctx) + ctx = replace(ctx, params={**ctx.params, "requestState": plaintext}) + result = await call_next(ctx) + return self._seal_result(ctx, result, binding) + + # -- inbound ------------------------------------------------------------ + + def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding | None]: + assert ctx.params is not None + wire = ctx.params["requestState"] + if not isinstance(wire, str): + _reject(ctx.method, "non-string requestState") + security = self._security + if security is None: + _reject(ctx.method, "requestState received but no request_state_security is configured") + if security.is_unprotected: + return wire, None + assert security.codec is not None + try: + payload = security.codec.unseal(wire) + except InvalidRequestState as exc: + _reject(ctx.method, str(exc)) + except Exception: # deny-on-error: a buggy custom codec must fail closed + logger.exception("requestState codec raised during unseal on %s", ctx.method) + _reject(ctx.method, "codec error") + try: + claims = json.loads(payload) + version, iat, exp, inner = claims["v"], claims["iat"], claims["exp"], claims["s"] + except (ValueError, KeyError, TypeError): + _reject(ctx.method, "malformed") + if version != _ENVELOPE_VERSION or not isinstance(inner, str): + _reject(ctx.method, "malformed") + now = time.time() + # Accept-conditions are stated positively so a claim that defeats + # comparison (a NaN smuggled through a weak custom codec) reads as + # unproven and rejects. + if not isinstance(iat, int | float) or not (iat <= now + _FUTURE_SKEW): + _reject(ctx.method, "minted in the future") + if not isinstance(exp, int | float) or not (now < exp): + _reject(ctx.method, "expired") + target, args_digest = _request_identity(ctx.method, ctx.params) + if claims.get("m") != ctx.method or claims.get("t") != target or claims.get("a") != args_digest: + _reject(ctx.method, "request binding") + if claims.get("aud") != self._audience: + _reject(ctx.method, "audience") # fail closed in BOTH directions + try: + principal = security.bind_principal(ctx) if security.bind_principal is not None else None + except Exception: # deny-on-error: a raising principal binding must fail closed + logger.exception("bind_principal raised while verifying requestState on %s", ctx.method) + _reject(ctx.method, "principal binding error") + claim = claims.get("p") + if (claim is None) != (principal is None): + _reject(ctx.method, "principal drift") # fail closed in BOTH directions + if claim is not None and principal is not None: + if not isinstance(claim, str) or not _principal_matches(claim, principal): + _reject(ctx.method, "principal") + return inner, (target, args_digest, principal) + + # -- outbound ----------------------------------------------------------- + + def _seal_result( + self, ctx: ServerRequestContext[Any, Any], result: HandlerResult, binding: _RoundBinding | None + ) -> HandlerResult: + # Results arrive as wire mappings on the spec path (serialization runs + # inside the chain); a middleware short-circuiting below the boundary + # may return a model. Both shapes are sealed. + if not is_input_required(result): + return result + state = result.get("requestState") if isinstance(result, Mapping) else result.request_state + if state is None: + return result + if ctx.method not in _MRTR_METHODS: + logger.error( + "handler for %s returned an input_required result carrying requestState, but the spec " + "restricts InputRequiredResult to tools/call, prompts/get, and resources/read; extension " + "and custom methods must not mint requestState. Refusing to send it.", + ctx.method, + ) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") + if isinstance(result, Mapping): + if not isinstance(state, str): + # Only a short-circuiting middleware can put a non-string here + # (the spec path validated the field as a string); there is no + # state for this module to seal. + return result + return {**result, "requestState": self._seal(ctx, state, binding)} + return result.model_copy(update={"request_state": self._seal(ctx, state, binding)}) + + def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str: + security = self._security + if security is None: + # Reachable only by an *undeclared* dynamic InputRequiredResult + # return (declared surfaces already failed at construction). Never + # emit unprotected state silently; tell the operator exactly what + # to do, in the log, and fail the request. + logger.error( + "handler for %s returned an InputRequiredResult with requestState, but no " + "request_state_security is configured on this server; refusing to send unprotected " + "state. Pass request_state_security=RequestStateSecurity(...) to MCPServer " + "(or .ephemeral() for single-process, or .unprotected() to accept the risk).", + ctx.method, + ) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") + if security.is_unprotected: + return state + assert security.codec is not None + if binding is None: + target, args_digest = _request_identity(ctx.method, ctx.params) + try: + principal = security.bind_principal(ctx) if security.bind_principal is not None else None + except Exception: # deny-on-error: a raising principal binding must not mint unbound state + logger.exception("bind_principal raised while sealing requestState on %s", ctx.method) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None + binding = (target, args_digest, principal) + target, args_digest, principal = binding + now = int(time.time()) + claims: dict[str, Any] = { + "v": _ENVELOPE_VERSION, + "iat": now, + "exp": now + security.ttl, + "m": ctx.method, + "t": target, + "a": args_digest, + "s": state, + } + if self._audience is not None: + claims["aud"] = self._audience + if principal is not None: + claims["p"] = _principal_claim(principal) + return security.codec.seal(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode()) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 6773fd4de..6aa9cd6d5 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -204,7 +204,7 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if (hint := self.server.cache_hints.get(method)) is not None: if isinstance(result, CacheableResult): result = apply_cache_hint(result, hint) - elif isinstance(result, Mapping) and result.get("resultType") != "input_required": + elif isinstance(result, Mapping) and not _methods.is_input_required(result): # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} # Dump and serialize inside the chain so the OpenTelemetry span (the diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 820478f3f..dbae4c0e0 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -42,7 +42,7 @@ from mcp.client.session import ClientRequestContext from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage from tests.interaction._connect import BASE_URL, mounted_app @@ -625,7 +625,7 @@ async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_res """When the server returns `InputRequiredResult` carrying an elicitation, `Client.call_tool` routes it to `elicitation_callback` and retries automatically — the caller sees only the terminal `CallToolResult`.""" - server = MCPServer("test") + server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) @server.tool() async def greet(ctx: Context) -> str | types.InputRequiredResult: @@ -662,7 +662,7 @@ async def elicitation_callback( async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `CreateMessageRequest` is routed to `sampling_callback` and the call retried with the model's reply.""" - server = MCPServer("test") + server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) @server.tool() async def ask(ctx: Context) -> str | types.InputRequiredResult: @@ -707,7 +707,7 @@ async def sampling_callback( async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `ListRootsRequest` is routed to `list_roots_callback` and the call retried with the returned roots.""" - server = MCPServer("test") + server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) @server.tool() async def count_roots(ctx: Context) -> str | types.InputRequiredResult: @@ -742,7 +742,7 @@ async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_thr """A three-round flow where each `InputRequiredResult.request_state` encodes the round number: the driver echoes it back byte-exact, the server advances per round, and the elicitation callback runs once per round.""" - server = MCPServer("test") + server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) @server.tool() async def multi(ctx: Context) -> str | types.InputRequiredResult: @@ -777,7 +777,7 @@ async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered( """SDK-defined: with no `elicitation_callback`, the default returns `ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError` rather than retrying.""" - server = MCPServer("test") + server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) @server.tool() async def needs_input(ctx: Context) -> str | types.InputRequiredResult: diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 110bd8f78..93434017c 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -18,9 +18,10 @@ TextContent, ) -from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004 +from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 from mcp import Client, MCPError from mcp.client import ClientRequestContext +from mcp.server.mcpserver import InvalidRequestState # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -161,3 +162,23 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None: ], ) ) + + +def test_a_custom_codec_round_trips_what_it_sealed() -> None: + """tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + assert token.startswith(tutorial005.PREFIX) + assert b"round-1" not in token.encode() + assert codec.unseal(token) == b"round-1" + + +def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None: + """tutorial005: a modified token and a token it never minted both raise `InvalidRequestState` - + the codec's whole contract. TTL, principal, and request binding are the SDK's job, not the codec's.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + with pytest.raises(InvalidRequestState): + codec.unseal(token + "00") # extra ciphertext bytes: authentication fails + with pytest.raises(InvalidRequestState): + codec.unseal("not-a-token") diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index b4a118458..0e72cb8c3 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -46,7 +46,7 @@ from mcp.client import Client from mcp.server.context import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity, ResourceSecurity from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource @@ -1867,7 +1867,10 @@ def get_user(user_id: str) -> str: async def test_tool_returning_input_required_result_reaches_client_unchanged(): - mcp = MCPServer() + # unprotected(): this test pins plaintext passthrough - the wire carries the + # handler's requestState exactly as written, the opt-out posture a + # declared-manual surface may choose. + mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) @mcp.tool() async def ask(ctx: Context) -> str | InputRequiredResult: @@ -1884,7 +1887,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: async def test_tool_reads_input_responses_and_request_state_from_context_on_retry(): - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.tool() async def greet(ctx: Context) -> str | InputRequiredResult: @@ -1942,8 +1945,11 @@ def _ask_who() -> ElicitRequest: async def test_prompt_returning_input_required_result_reaches_client_unchanged(): """A prompt function may return an InputRequiredResult and the pipeline passes it - through to the client (spec-mandated: SEP-2322 allows it on prompts/get).""" - mcp = MCPServer() + through to the client (spec-mandated: SEP-2322 allows it on prompts/get). + + unprotected(): the assertion is on the verbatim wire requestState, the + opt-out posture a declared-manual surface may choose.""" + mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -1962,7 +1968,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_prompt_reads_input_responses_and_request_state_from_context_on_retry(): """The prompts/get retry carries input_responses and request_state to the prompt function via the Context, completing the SEP-2322 multi-round-trip flow.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -1994,7 +2000,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so the runner rejects the frame with -32603 — the same posture the tools path has.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -2010,7 +2016,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate for resources/read: a pre-2026 session has no input_required vocabulary, so the runner rejects the frame with -32603.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2025,8 +2031,11 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_resource_template_returning_input_required_result_reaches_client_unchanged(): """A resource template function may return an InputRequiredResult and the pipeline - passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read).""" - mcp = MCPServer() + passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read). + + unprotected(): the assertion is on the verbatim wire requestState, the + opt-out posture a declared-manual surface may choose.""" + mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2045,7 +2054,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_resource_template_reads_input_responses_from_context_on_retry(): """The resources/read retry carries input_responses to the template function via the Context, completing the SEP-2322 multi-round-trip flow.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2076,7 +2085,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_context_read_resource_raises_on_input_required_result(): """ctx.read_resource is a content reader: an InputRequiredResult from the template raises with a pointer at the forwarding path instead of widening every caller.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2094,7 +2103,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_mcpserver_read_resource_returns_input_required_result_for_handler_forwarding(): """MCPServer.read_resource hands the template's InputRequiredResult to a direct caller unchanged — the composition path for a handler that forwards it as its own result.""" - mcp = MCPServer() + mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) sentinel = InputRequiredResult(input_requests={"who": _ask_who()}) @mcp.resource("ask://{topic}") @@ -2109,8 +2118,12 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template(): """ctx.read_resource never participates in the multi-round-trip flow, so the nested template must not see the outer request's input_responses/request_state — a colliding - key would otherwise consume an answer meant for the outer handler's own question.""" - mcp = MCPServer() + key would otherwise consume an answer meant for the outer handler's own question. + + unprotected(): the probe below is client-built plaintext state that must reach + the outer request's context as-sent - the subject is nested-context isolation, + not the wire seal (no surface here mints state at all).""" + mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) seen_responses: list[InputResponses | None] = [] seen_state: list[str | None] = [] diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py new file mode 100644 index 000000000..2a12a775c --- /dev/null +++ b/tests/server/test_request_state.py @@ -0,0 +1,494 @@ +"""`mcp.server.request_state` unit tier: the `AESGCMRequestStateCodec` token +format, tamper rejection, and key-ring rotation, plus the `RequestStateSecurity` +policy object and the `authenticated_principal` default binding. Wire-level +boundary behavior lives in its own phase; nothing here crosses a transport.""" + +import base64 +import string +from collections.abc import Callable +from typing import Any, cast + +import pytest +from inline_snapshot import snapshot + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.context import ServerRequestContext +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateSecurity, + authenticated_principal, +) + +_TOKEN_PREFIX = "v1." +_KID_LEN = 4 +_NONCE_LEN = 12 +_GCM_TAG_LEN = 16 +_BODY_FLOOR = _KID_LEN + _NONCE_LEN + _GCM_TAG_LEN +_B64URL_ALPHABET = set(string.ascii_letters + string.digits + "-_") + +_KEY_A = b"a" * 32 +_KEY_B = b"b" * 32 +_KEY_OLD = b"o" * 32 +_KEY_NEW = b"n" * 32 + +# Distinctive plaintext: opacity and log-secrecy assertions search for it. +_PAYLOAD = b"sentinel-plaintext-3f9c" +# `InvalidRequestState` messages are log-only reason codes ("malformed", +# "unknown key", ...), never prose and never payload material. +_REASON_CODE_MAX_LEN = 40 + + +def _b64u_nopad(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _decode_body(token: str) -> bytes: + body = token.removeprefix(_TOKEN_PREFIX) + return base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)) + + +def _flip_body_byte(token: str, index: int) -> str: + raw = bytearray(_decode_body(token)) + raw[index] ^= 0xFF + return _TOKEN_PREFIX + _b64u_nopad(bytes(raw)) + + +def _flip_prefix_char(token: str) -> str: + return "x" + token[1:] + + +def _flip_kid_byte(token: str) -> str: + return _flip_body_byte(token, 0) + + +def _flip_nonce_byte(token: str) -> str: + return _flip_body_byte(token, _KID_LEN) + + +def _flip_ciphertext_byte(token: str) -> str: + return _flip_body_byte(token, _KID_LEN + _NONCE_LEN) + + +def _flip_tag_byte(token: str) -> str: + return _flip_body_byte(token, -1) + + +def _inject_junk_chars(body: str) -> str: + return body[:10] + "!@\n*" + body[10:] + + +def _append_newline(body: str) -> str: + return body + "\n" + + +def _append_padding(body: str) -> str: + return body + "=" * (-len(body) % 4 or 4) + + +def _bare_context() -> ServerRequestContext[Any, Any]: + return ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + ) + + +class _StaticCodec: + """Minimal `RequestStateCodec` stand-in for policy tests; no real crypto.""" + + def seal(self, payload: bytes) -> str: + return payload.hex() + + def unseal(self, token: str) -> bytes: + return bytes.fromhex(token) + + +# -- AESGCMRequestStateCodec -------------------------------------------------- + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param(b"", id="empty"), + pytest.param(b"plain ascii state", id="ascii"), + pytest.param("ünïcødé – 状態".encode(), id="multi-byte-utf8"), + pytest.param(bytes(range(256)), id="raw-binary"), + pytest.param(bytes(range(256)) * 256, id="64KiB"), + ], +) +def test_seal_unseal_round_trips_any_payload(payload: bytes) -> None: + """SDK-defined: the codec is byte-transparent — empty, text, raw-binary, and + large payloads all survive seal/unseal unchanged.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + assert codec.unseal(codec.seal(payload)) == payload + + +def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext() -> None: + """SDK-defined token format: "v1." then unpadded base64url whose decoded body + is kid(4) || nonce(12) || GCM ciphertext+tag (payload length + 16).""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + assert token.startswith(_TOKEN_PREFIX) + body = token.removeprefix(_TOKEN_PREFIX) + assert "=" not in body + assert set(body) <= _B64URL_ALPHABET + assert len(_decode_body(token)) == _KID_LEN + _NONCE_LEN + len(_PAYLOAD) + _GCM_TAG_LEN + + +def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal() -> None: + """SDK-defined: every seal draws a fresh nonce, so identical payloads yield + distinct tokens — and each one independently verifies.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + first = codec.seal(_PAYLOAD) + second = codec.seal(_PAYLOAD) + assert first != second + assert codec.unseal(first) == _PAYLOAD + assert codec.unseal(second) == _PAYLOAD + + +@pytest.mark.parametrize( + "corrupt", + [ + pytest.param(_flip_prefix_char, id="prefix-char"), + pytest.param(_flip_kid_byte, id="kid-byte"), + pytest.param(_flip_nonce_byte, id="nonce-byte"), + pytest.param(_flip_ciphertext_byte, id="ciphertext-byte"), + pytest.param(_flip_tag_byte, id="tag-byte"), + ], +) +def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload( + corrupt: Callable[[str], str], +) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): state that fails + verification is rejected — flipping any region of the token (prefix, kid, + nonce, ciphertext, tag) raises, with only a short reason code in the message.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + token = codec.seal(_PAYLOAD) + with pytest.raises(InvalidRequestState) as exc: + codec.unseal(corrupt(token)) + message = str(exc.value) + assert len(message) <= _REASON_CODE_MAX_LEN + assert _PAYLOAD.decode() not in message + + +@pytest.mark.parametrize( + "token", + [ + pytest.param("", id="empty-string"), + pytest.param(_b64u_nopad(b"\x00" * 64), id="missing-prefix"), + pytest.param(_TOKEN_PREFIX + "!!!not-base64!!!", id="garbage-after-prefix"), + pytest.param(_TOKEN_PREFIX + _b64u_nopad(b"\x00" * (_BODY_FLOOR - 1)), id="below-floor"), + ], +) +def test_a_structurally_malformed_token_is_rejected(token: str) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec + could never have minted — empty, unprefixed, undecodable, or shorter than the + kid+nonce+tag floor — fail verification.""" + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_A]).unseal(token) + + +def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): a token sealed + under a key this ring never held fails its O(1) kid lookup, with the log-only + reason "unknown key".""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + with pytest.raises(InvalidRequestState) as exc: + AESGCMRequestStateCodec([_KEY_B]).unseal(token) + assert str(exc.value) == "unknown key" + + +@pytest.mark.parametrize( + "ring", + [ + pytest.param([_KEY_OLD, _KEY_NEW], id="rotation-phase-1"), + pytest.param([_KEY_NEW, _KEY_OLD], id="rotation-phase-2"), + ], +) +def test_a_token_minted_under_the_old_key_unseals_under_any_ring_containing_it(ring: list[bytes]) -> None: + """SDK-defined rotation: every ring key verifies, so in-flight old-key state + survives both the [old, new] and the [new, old] rollout phases.""" + token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec(ring).unseal(token) == _PAYLOAD + + +def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None: + """SDK-defined rotation: keys[0] is the minter — phase-2 [new, old] state + verifies under a [new]-only ring and fails under an [old]-only ring.""" + token = AESGCMRequestStateCodec([_KEY_NEW, _KEY_OLD]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) == _PAYLOAD + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_OLD]).unseal(token) + + +def test_a_token_minted_under_a_retired_key_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): once rotation + completes ([old] -> [new]), state minted under the retired key fails + verification and the client must restart the flow.""" + token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) + + +def test_an_empty_key_ring_is_rejected_at_construction() -> None: + """SDK-defined: a codec with nothing to mint under is a configuration error, + caught at construction rather than on the first seal.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([]) + assert str(exc.value) == snapshot("AESGCMRequestStateCodec requires at least one key") + + +def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> None: + """SDK-defined: keys carry at least 32 bytes of secret material; the + construction error tells the operator how to generate one.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([b"k" * 31]) + assert str(exc.value) == snapshot( + "request-state keys must be at least 32 bytes of secret randomness; keys[0] is 31 bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + + +def test_a_duplicate_key_in_the_ring_is_rejected_at_construction() -> None: + """SDK-defined: two ring slots holding the same key is a rotation mistake + (the duplicate could never be retired independently), caught at construction.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([_KEY_A, _KEY_A]) + assert str(exc.value) == snapshot("keys[1] duplicates an earlier ring key") + + +def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> None: + """SDK-defined: a ring entry that is not bytes/bytearray/str is a TypeError at + construction — never coerced (bytes(32) would silently build an all-zero key) — + naming the offending index and type, through the codec and the policy alike.""" + with pytest.raises(TypeError) as exc: + AESGCMRequestStateCodec([_KEY_A, cast("Any", 32)]) + assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[1] is int") + with pytest.raises(TypeError) as exc: + RequestStateSecurity(keys=[cast("Any", 32)]) + assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[0] is int") + + +def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None: + """SDK-defined: the three documented key spellings interoperate in one ring, and + each entry can mint a token the mixed ring verifies.""" + codec = AESGCMRequestStateCodec([_KEY_A, bytearray(_KEY_B), "c" * 32]) + assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + assert codec.unseal(AESGCMRequestStateCodec([bytearray(_KEY_B)]).seal(_PAYLOAD)) == _PAYLOAD + assert codec.unseal(AESGCMRequestStateCodec(["c" * 32]).seal(_PAYLOAD)) == _PAYLOAD + + +def test_a_str_key_is_equivalent_to_its_utf8_bytes_form() -> None: + """SDK-defined: str keys are utf-8 encoded before derivation, so "k"*32 and + b"k"*32 are the same ring key — tokens cross between the two spellings.""" + token = AESGCMRequestStateCodec(["k" * 32]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec([b"k" * 32]).unseal(token) == _PAYLOAD + + +def test_bytearray_key_material_is_copied_at_construction() -> None: + """SDK-defined: key bytes are copied at construction, so mutating the + caller-held bytearray afterwards changes neither verification of existing + tokens nor the key new tokens are minted under.""" + material = bytearray(b"m" * 32) + codec = AESGCMRequestStateCodec([cast("Any", material)]) + minted_before_mutation = codec.seal(_PAYLOAD) + material[:] = b"X" * 32 + assert codec.unseal(minted_before_mutation) == _PAYLOAD + assert AESGCMRequestStateCodec([b"m" * 32]).unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes() -> None: + """SDK-defined: the token is encrypted, not merely signed — the plaintext + appears in neither the token string (directly, base64url'd, or hex'd) nor + the decoded token body.""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + assert _PAYLOAD.decode() not in token + assert _b64u_nopad(_PAYLOAD) not in token + assert _PAYLOAD.hex() not in token + assert _PAYLOAD not in _decode_body(token) + + +def test_every_substitution_of_the_final_token_character_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4) via strict canonical + decoding: the final base64url character's don't-care padding bits no longer make + variants decode identically — all 63 single-character substitutions at the last + position fail verification.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) + substitutions = [c for c in sorted(_B64URL_ALPHABET) if c != body[-1]] + assert len(substitutions) == 63 + for c in substitutions: + with pytest.raises(InvalidRequestState): + codec.unseal(_TOKEN_PREFIX + body[:-1] + c) + + +@pytest.mark.parametrize( + "mangle", + [ + pytest.param(_inject_junk_chars, id="junk-chars-injected"), + pytest.param(_append_newline, id="newline-appended"), + pytest.param(_append_padding, id="padding-appended"), + ], +) +def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4) via strict canonical + decoding: a lax decoder discards non-alphabet characters and tolerates appended + padding, so infinitely many strings would alias one minted token; only the exact + canonical encoding verifies.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) + with pytest.raises(InvalidRequestState): + codec.unseal(_TOKEN_PREFIX + mangle(body)) + + +def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the format + prefix is bound under the authentication tag (RFC 8725 discipline), so a v1 + token cannot be replayed as "v2.".""" + codec = AESGCMRequestStateCodec([_KEY_A]) + token = codec.seal(_PAYLOAD) + with pytest.raises(InvalidRequestState): + codec.unseal("v2." + token.removeprefix(_TOKEN_PREFIX)) + + +def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is bound + under the authentication tag, so grafting one valid token's key fingerprint + onto another valid token's nonce+ciphertext fails verification even when the + verifier's ring knows both keys.""" + raw_a = _decode_body(AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)) + raw_b = _decode_body(AESGCMRequestStateCodec([_KEY_B]).seal(_PAYLOAD)) + assert raw_a[:_KID_LEN] != raw_b[:_KID_LEN] + transplanted = _TOKEN_PREFIX + _b64u_nopad(raw_a[:_KID_LEN] + raw_b[_KID_LEN:]) + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_A, _KEY_B]).unseal(transplanted) + + +# -- RequestStateSecurity ----------------------------------------------------- + + +def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: + """SDK-defined: keys= and codec= are mutually exclusive spellings of the same + decision; passing both is ambiguous and fails immediately.""" + with pytest.raises(ValueError) as exc: + RequestStateSecurity(keys=[_KEY_A], codec=_StaticCodec()) + assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") + + +def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: + """SDK-defined: there is no implicit default protection — a policy must name + its codec (or opt out via unprotected()), so the bare constructor fails.""" + with pytest.raises(ValueError) as exc: + RequestStateSecurity() + assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") + + +@pytest.mark.parametrize( + "ttl", + [ + pytest.param(0.0, id="zero"), + pytest.param(-600.0, id="negative"), + pytest.param(float("nan"), id="nan"), + pytest.param(float("inf"), id="inf"), + ], +) +def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl: float) -> None: + """SDK-defined: ttl bounds per-round client think time, so zero and negative values + (state that could never verify), NaN (every expiry comparison would read as + unexpired), and infinity (state that never expires) all fail at construction — for + explicit keys and for ephemeral() alike.""" + with pytest.raises(ValueError, match="positive finite"): + RequestStateSecurity(keys=[_KEY_A], ttl=ttl) + with pytest.raises(ValueError, match="positive finite"): + RequestStateSecurity.ephemeral(ttl=ttl) + + +def test_keys_produce_a_working_built_in_codec_on_the_policy() -> None: + """SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on + .codec and immediately able to round-trip.""" + security = RequestStateSecurity(keys=[_KEY_A]) + assert isinstance(security.codec, AESGCMRequestStateCodec) + assert security.codec.unseal(security.codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None: + """SDK-defined: codec=... stores the caller's object identically — no + wrapping, so calls through .codec hit the custom implementation directly.""" + codec = _StaticCodec() + security = RequestStateSecurity(codec=codec) + assert security.codec is codec + assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None: + """SDK-defined: ephemeral() is real protection (not an opt-out) under a key + held only by its own process — so a sibling ephemeral() instance rejects its + tokens, the documented single-process limitation.""" + first = RequestStateSecurity.ephemeral() + second = RequestStateSecurity.ephemeral() + assert first.is_unprotected is False + assert first.codec is not None + assert second.codec is not None + token = first.codec.seal(_PAYLOAD) + assert first.codec.unseal(token) == _PAYLOAD + with pytest.raises(InvalidRequestState): + second.codec.unseal(token) + + +def test_an_unprotected_policy_has_no_codec_no_principal_binding_and_no_audience() -> None: + """SDK-defined: unprotected() is the explicit opt-out — is_unprotected is + True and there is no codec, principal binding, or audience to apply.""" + security = RequestStateSecurity.unprotected() + assert security.is_unprotected is True + assert security.codec is None + assert security.bind_principal is None + assert security.audience is None + + +def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None: + """SDK-defined: `audience` is stored as given for the boundary to stamp and verify; + the default None leaves the decision to the server tier's `default_audience`.""" + assert RequestStateSecurity(keys=[_KEY_A]).audience is None + assert RequestStateSecurity(keys=[_KEY_A], audience="svc").audience == "svc" + assert RequestStateSecurity.ephemeral(audience="svc").audience == "svc" + + +def test_the_default_principal_binding_is_authenticated_principal() -> None: + """SDK-defined: principal binding is on by default — an unconfigured policy + binds state to the authenticated OAuth client.""" + assert RequestStateSecurity(keys=[_KEY_A]).bind_principal is authenticated_principal + + +def test_an_explicit_principal_binding_callable_is_stored() -> None: + """SDK-defined: a custom bind_principal callable is stored as given, so the + boundary later invokes exactly the operator's identity function.""" + + def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None: + return "tenant-1" + + security = RequestStateSecurity(keys=[_KEY_A], bind_principal=tenant_binding) + assert security.bind_principal is tenant_binding + assert tenant_binding(_bare_context()) == "tenant-1" + + +# -- authenticated_principal ---------------------------------------------------- + + +def test_authenticated_principal_is_none_without_an_auth_context() -> None: + """SDK-defined: on unauthenticated transports there is no access token in + context, so the default binding derives no principal.""" + assert authenticated_principal(_bare_context()) is None + + +def test_authenticated_principal_returns_the_access_tokens_client_id() -> None: + """SDK-defined: with an access token in the auth context (as + AuthContextMiddleware sets it), the default binding is that token's client_id.""" + user = AuthenticatedUser(AccessToken(token="at-1", client_id="client-123", scopes=[])) + reset = auth_context_var.set(user) + try: + assert authenticated_principal(_bare_context()) == "client-123" + finally: + auth_context_var.reset(reset) diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py new file mode 100644 index 000000000..1838ab80f --- /dev/null +++ b/tests/server/test_request_state_boundary.py @@ -0,0 +1,1193 @@ +"""`mcp.server.request_state`: the `RequestStateBoundary` middleware and its claims +envelope, proven through the public wire surfaces — `requestState` is sealed on the way +out, verified and restored on the way back, and every verification failure collapses to +one frozen wire error (MCP 2026-07-28, basic/patterns/mrtr server requirements 4-5). + +Servers here use MANUAL multi-round-trip tools (a plain `@mcp.tool()` returning +`str | InputRequiredResult` that reads `ctx.input_responses` / `ctx.request_state`), +driven by the manual client loop on `client.session.call_tool`. +""" + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any, cast + +import anyio +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + CallToolRequestParams, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + ListToolsResult, + PaginatedRequestParams, + ReadResourceResult, + RequestParams, + TextContent, + TextResourceContents, + Tool, +) + +import mcp.server.request_state as request_state_module +from mcp import Client +from mcp.server import MCPServer, Server, ServerRequestContext +from mcp.server.context import HandlerResult +from mcp.server.mcpserver import Context +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateBoundary, + RequestStateSecurity, +) +from mcp.shared.exceptions import MCPError + +from .test_runner import connected_runner + +pytestmark = pytest.mark.anyio + +_KEY = b"0123456789abcdef0123456789abcdef" # 32 bytes; a test fixture, not a secret +_T0 = 1_782_345_600.0 # frozen mint instant for clock-controlled tests +_TTL = 600.0 + + +def _ask(message: str) -> ElicitRequest: + """A minimal elicitation request for a manual tool's `input_requests`.""" + return ElicitRequest( + params=ElicitRequestFormParams( + message=message, + requested_schema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + ) + + +def _accept() -> ElicitResult: + return ElicitResult(action="accept", content={"confirm": True}) + + +async def _list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + """Minimal listing for lowlevel fixtures: `ClientSession.call_tool` consults + tools/list for output-schema validation, so the server must answer it.""" + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + +class _PassthroughCodec: + """A contract-valid codec with no crypto: the token IS the payload bytes. + + Lets a test place arbitrary payload bytes behind a successful unseal, to + prove the boundary's own claims checks reject what a codec cannot vouch for. + """ + + def seal(self, payload: bytes) -> str: + return payload.decode() + + def unseal(self, token: str) -> bytes: + return token.encode() + + +class _CustomMethodParams(RequestParams): + """Params for a lowlevel custom (extension-style) method in the off-set tests.""" + + request_state: str | None = None + + +class _Clock: + """Stands in for the `time` module inside `mcp.server.request_state`.""" + + def __init__(self, now: float) -> None: + self.now = now + + def time(self) -> float: + return self.now + + +def _tamper(token: str) -> str: + """Flip one mid-token character. Strict canonical decoding means any single-character + change rejects — including the final char, whose don't-care padding bits a lax decoder + would ignore (pinned by the canonicality tests in test_request_state.py).""" + i = len(token) // 2 + return token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] + + +def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: + """The single frozen wire shape for every inbound verification failure. + + Frozen contract — asserted explicitly, never snapshotted. + """ + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "Invalid or expired requestState" + assert exc.value.error.data == {"reason": "invalid_request_state"} + + +def _manual_server( + security: RequestStateSecurity, *, state: str = "awaiting-confirm", name: str = "manual" +) -> tuple[MCPServer, list[str | None]]: + """An MCPServer with one manual MRTR tool: round 1 asks, the retry records the + restored `ctx.request_state` and completes. `name` is also the boundary's default + audience.""" + seen: list[str | None] = [] + mcp = MCPServer(name, request_state_security=security) + + @mcp.tool() + async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(f"Deploy to {env}?")}, request_state=state) + seen.append(ctx.request_state) + return f"deployed to {env}" + + return mcp, seen + + +async def _first_round(client: Client, name: str, args: dict[str, Any]) -> str: + """Round 1 of the manual loop: call without responses, return the wire token.""" + first = await client.session.call_tool(name, args, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + return first.request_state + + +async def _retry(client: Client, name: str, args: dict[str, Any], token: str) -> CallToolResult | InputRequiredResult: + """The retry round: echo the wire token with the elicited answer attached.""" + return await client.session.call_tool( + name, args, input_responses={"confirm": _accept()}, request_state=token, allow_input_required=True + ) + + +# -- end-to-end seal/unseal through the public surfaces ------------------------------- + + +async def test_request_state_is_sealed_on_the_wire_and_restored_for_the_handler() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): the wire carries an + opaque integrity-protected token — never the handler's plaintext — and a faithful + echo hands the handler back exactly the state it minted.""" + plaintext = "awaiting-confirm:9f2e" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY]), state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await _retry(client, "deploy", {"env": "prod"}, first.request_state) + + assert isinstance(second, CallToolResult) + assert not second.is_error + assert isinstance(second.content[0], TextContent) + assert second.content[0].text == "deployed to prod" + assert seen == [plaintext] + + +async def test_lowlevel_server_gets_identical_sealing_from_the_one_line_middleware_append() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): appending the public + `RequestStateBoundary` to `Server.middleware` gives the lowlevel tier the same sealed + wire and the same plaintext restore — nothing is private to MCPServer.""" + plaintext = "lowlevel-round-1" + seen: list[str | None] = [] + + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Proceed?")}, request_state=plaintext) + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + + with anyio.fail_after(5): + async with Client(server) as client: + first = await client.session.call_tool("t", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await _retry(client, "t", {}, first.request_state) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +async def test_a_resource_template_flow_seals_on_resources_read_and_restores_the_plaintext() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): resources/read is an + MRTR carrier too — a template's `requestState` crosses the wire sealed and bound to + the originating uri, and the faithful retry hands the template function back its + plaintext.""" + plaintext = "resource-round-1" + seen: list[str | None] = [] + mcp = MCPServer("templated", request_state_security=RequestStateSecurity(keys=[_KEY])) + + @mcp.resource("deploy://{env}/confirm") + async def confirm(env: str, ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(f"Read {env}?")}, request_state=plaintext) + seen.append(ctx.request_state) + return f"confirmed {env}" + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.read_resource("deploy://prod/confirm", allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await client.session.read_resource( + "deploy://prod/confirm", + input_responses={"confirm": _accept()}, + request_state=first.request_state, + allow_input_required=True, + ) + + assert isinstance(second, ReadResourceResult) + assert isinstance(second.contents[0], TextResourceContents) + assert second.contents[0].text == "confirmed prod" + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(first.request_state)) + assert (claims["m"], claims["t"], claims["s"]) == ("resources/read", "deploy://prod/confirm", plaintext) + assert seen == [plaintext] + + +# -- verification failures: tamper, expiry, future skew ------------------------------- + + +async def test_tampered_request_state_is_rejected_with_the_frozen_wire_error() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a modified echo fails + authentication and is rejected with the frozen -32602 shape; the handler never runs.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, _tamper(token)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_expired_request_state_is_rejected_and_just_inside_ttl_is_accepted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5, expiration bound): one + second past `ttl` is the frozen rejection; one second inside completes the flow.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) # minted at _T0 + clock.now = _T0 + _TTL + 1 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + _TTL - 1 + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_state_minted_in_the_future_is_rejected_beyond_the_sixty_second_skew( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): a token minted 120 s + ahead of the verifier's clock is rejected; 30 s ahead is inside the skew allowance.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) # minted at _T0 + clock.now = _T0 - 120 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 - 30 + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- request binding ------------------------------------------------------------------- + + +async def test_round_one_state_replayed_on_a_different_tool_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4, originating-request + binding): a token minted for tool A fails verification when echoed on tool B of the + same server, while the faithful retry on tool A still completes.""" + seen: list[str | None] = [] + + def make_tool(state: str) -> Callable[[Context], Awaitable[str | InputRequiredResult]]: + async def tool(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(state)}, request_state=state) + seen.append(ctx.request_state) + return "done" + + return tool + + mcp = MCPServer("two-tools", request_state_security=RequestStateSecurity(keys=[_KEY])) + mcp.tool(name="alpha")(make_tool("alpha-state")) + mcp.tool(name="beta")(make_tool("beta-state")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "alpha", {}) + with pytest.raises(MCPError) as exc: + await _retry(client, "beta", {}, token) + second = await _retry(client, "alpha", {}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["alpha-state"] + + +async def test_retry_with_different_arguments_is_rejected_and_the_original_arguments_complete() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4, argument binding): the + same tool retried with different arguments is the frozen rejection; the retry that + repeats the original arguments completes.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "staging"}, token) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- principal binding ----------------------------------------------------------------- + + +async def test_state_minted_with_a_principal_is_rejected_when_the_verifier_derives_none() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): principal + binding fails closed — state sealed for a principal is rejected by a round on which + `bind_principal` derives none.""" + principal: list[str | None] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = None + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_state_minted_without_a_principal_is_rejected_when_the_verifier_derives_one() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): the other + drift direction also fails closed — unbound state is rejected once the verifying + round derives a principal.""" + principal: list[str | None] = [None] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = "alice" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_state_for_a_different_principal_is_rejected_and_the_same_principal_completes() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): a token + minted for one principal is rejected when echoed by another, and accepted when the + same principal returns.""" + principal: list[str | None] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = "bob" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + principal[0] = "alice" + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_a_principal_binding_that_raises_fails_the_seal_as_an_internal_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined fail-safe: a raising `bind_principal` must not mint unbound state — + the round fails as a bare internal error and the traceback stays in the server log.""" + + def boom(ctx: ServerRequestContext[Any, Any]) -> str | None: + raise RuntimeError("identity provider down") + + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=boom)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + assert exc.value.error.data is None # the reason never reaches the wire + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_a_principal_binding_that_raises_fails_the_unseal_with_the_frozen_rejection( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined fail-safe: a `bind_principal` that raises while verifying must not + bypass the frozen contract — the round collapses to the frozen -32602 and the + traceback stays in the server log.""" + rounds: list[int] = [] + + def flaky(ctx: ServerRequestContext[Any, Any]) -> str | None: + rounds.append(1) + if len(rounds) == 1: + return "alice" # the mint succeeds... + raise RuntimeError("identity provider down") # ...the verify round raises + + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=flaky)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_two_mints_for_the_same_principal_carry_different_salted_principal_claims() -> None: + """SDK-defined: the `p` claim is salted per mint, so two tokens for the same + principal are not linkable by their principal digests (and `p` is present whenever a + principal is bound).""" + mcp, _ = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token_one = await _first_round(client, "deploy", {"env": "prod"}) + token_two = await _first_round(client, "deploy", {"env": "prod"}) + + codec = AESGCMRequestStateCodec([_KEY]) + claims_one = json.loads(codec.unseal(token_one)) + claims_two = json.loads(codec.unseal(token_two)) + assert "p" in claims_one + assert "p" in claims_two + assert claims_one["p"] != claims_two["p"] + + +# -- audience binding ------------------------------------------------------------------ + + +async def test_two_servers_sharing_a_key_reject_each_others_state_via_the_name_audience() -> None: + """SDK-defined: `MCPServer` wires its server name as the boundary's default audience, + so two services sharing (or accidentally reusing) a secret reject each other's state + out of the box — while each still completes its own flow.""" + mcp_billing, seen_billing = _manual_server(RequestStateSecurity(keys=[_KEY]), name="billing") + mcp_payments, seen_payments = _manual_server(RequestStateSecurity(keys=[_KEY]), name="payments") + + with anyio.fail_after(5): + async with Client(mcp_billing) as billing, Client(mcp_payments) as payments: + token = await _first_round(billing, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(payments, "deploy", {"env": "prod"}, token) + second = await _retry(billing, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen_billing == ["awaiting-confirm"] + assert seen_payments == [] + + +async def test_audience_presence_drift_is_rejected_in_both_directions() -> None: + """SDK-defined fail-closed: state minted with an audience is rejected by a boundary + expecting none, and audience-unbound state is rejected by a boundary expecting one — + while each boundary still accepts its own mint (lowlevel tier: the audience is the + boundary's `default_audience`, no MCPServer involved).""" + + def make_server(boundary: RequestStateBoundary) -> Server: + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="round-1") + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + server.middleware.append(boundary) + return server + + security = RequestStateSecurity(keys=[_KEY]) + bound = make_server(RequestStateBoundary(security, default_audience="svc")) + unbound = make_server(RequestStateBoundary(security)) + + with anyio.fail_after(5): + async with Client(bound) as on_bound, Client(unbound) as on_unbound: + bound_token = await _first_round(on_bound, "t", {}) + unbound_token = await _first_round(on_unbound, "t", {}) + with pytest.raises(MCPError) as bound_state_on_unbound: + await _retry(on_unbound, "t", {}, bound_token) + with pytest.raises(MCPError) as unbound_state_on_bound: + await _retry(on_bound, "t", {}, unbound_token) + assert isinstance(await _retry(on_bound, "t", {}, bound_token), CallToolResult) + assert isinstance(await _retry(on_unbound, "t", {}, unbound_token), CallToolResult) + + _assert_frozen_rejection(bound_state_on_unbound) + _assert_frozen_rejection(unbound_state_on_bound) + + +async def test_an_explicit_policy_audience_overrides_the_server_name_default() -> None: + """SDK-defined: `RequestStateSecurity(audience=...)` wins over the server-name + default — the envelope carries the policy's audience and the flow still completes.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], audience="prod-fleet"), name="one-box") + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(token)) + assert claims["aud"] == "prod-fleet" + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- claims envelope (white-box through the public codec) ----------------------------- + + +async def test_claims_envelope_carries_the_documented_fields_and_omits_p_when_unbound() -> None: + """SDK-defined envelope contract: the sealed payload is the documented claims JSON — + version, mint/expiry stamps, originating method/target/argument digest, the audience + (an MCPServer defaults it to the server name), and the plaintext — with no `p` claim + when `bind_principal` returns None.""" + plaintext = "step-one" + mcp, _ = _manual_server( + RequestStateSecurity(keys=[_KEY], ttl=_TTL, bind_principal=lambda ctx: None), state=plaintext + ) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(token)) + assert set(claims) == {"v", "iat", "exp", "m", "t", "a", "s", "aud"} + assert claims["v"] == 1 + assert claims["exp"] == claims["iat"] + int(_TTL) + assert claims["m"] == "tools/call" + assert claims["t"] == "deploy" + assert isinstance(claims["a"], str) and claims["a"] + assert claims["aud"] == "manual" # the MCPServer name, the boundary's default audience + assert claims["s"] == plaintext + + +async def test_each_round_is_resealed_with_a_fresh_token_and_a_restamped_iat( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SDK-defined: a multi-round flow reseals every round — round 2's token differs from + round 1's and carries a fresh mint stamp, so `ttl` bounds per-round think time rather + than total flow time.""" + mcp = MCPServer("wizard-server", request_state_security=RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + + @mcp.tool() + async def wizard(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"first": _ask("First?")}, request_state="step-1") + if ctx.request_state == "step-1": + return InputRequiredResult(input_requests={"second": _ask("Second?")}, request_state="step-2") + return "done" + + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("wizard", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + clock.now = _T0 + 5 + second = await client.session.call_tool( + "wizard", + {}, + input_responses={"first": _accept()}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.request_state is not None + third = await client.session.call_tool( + "wizard", + {}, + input_responses={"second": _accept()}, + request_state=second.request_state, + allow_input_required=True, + ) + + assert isinstance(third, CallToolResult) + assert first.request_state != second.request_state + codec = AESGCMRequestStateCodec([_KEY]) + claims_one = json.loads(codec.unseal(first.request_state)) + claims_two = json.loads(codec.unseal(second.request_state)) + assert claims_two["iat"] >= claims_one["iat"] + assert (claims_one["iat"], claims_two["iat"]) == (int(_T0), int(_T0) + 5) + + +# -- unconfigured boundary (lowlevel tier, no startup gate) ---------------------------- + + +async def test_an_unconfigured_boundary_rejects_inbound_request_state_before_the_handler() -> None: + """Spec-mandated fail-safe (basic/patterns/mrtr server requirement 5): a server that + never minted state rejects any inbound echo with the frozen error before the handler + runs; a request without `requestState` is untouched.""" + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: + calls.append(params.name) + return CallToolResult(content=[TextContent(text="ran")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + server.middleware.append(RequestStateBoundary(None)) + + with anyio.fail_after(5): + async with Client(server) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("t", {}, request_state="v1.forged", allow_input_required=True) + assert calls == [] + ok = await client.session.call_tool("t", {}) + + _assert_frozen_rejection(exc) + assert isinstance(ok, CallToolResult) + assert calls == ["t"] + + +async def test_an_unconfigured_boundary_answers_outbound_state_with_internal_error_and_logs_remediation( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined fail-safe: an unconfigured boundary never forwards plaintext state — + the request fails as a bare internal error and the full remediation (and nothing + secret) goes to the server log.""" + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> InputRequiredResult: + return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="oops-plaintext") + + server = Server("srv", on_call_tool=call_tool) + server.middleware.append(RequestStateBoundary(None)) + + with anyio.fail_after(5): + async with Client(server) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("t", {}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + + errors = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.ERROR] + assert len(errors) == 1 + assert errors[0].getMessage() == snapshot( + "handler for tools/call returned an InputRequiredResult with requestState, but no " + "request_state_security is configured on this server; refusing to send unprotected " + "state. Pass request_state_security=RequestStateSecurity(...) to MCPServer " + "(or .ephemeral() for single-process, or .unprotected() to accept the risk)." + ) + assert "oops-plaintext" not in caplog.text + + +# -- explicit opt-out ------------------------------------------------------------------ + + +async def test_unprotected_mode_passes_request_state_through_verbatim() -> None: + """SDK-defined: `RequestStateSecurity.unprotected()` is the spec's explicit opt-out + (MAY omit protection when tampering can cause nothing worse than request failure) — + the wire carries exactly the handler's plaintext and a verbatim echo is accepted.""" + plaintext = "plain-wizard-state" + mcp, seen = _manual_server(RequestStateSecurity.unprotected(), state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state == plaintext + second = await _retry(client, "deploy", {"env": "prod"}, plaintext) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +# -- malformed wire input -------------------------------------------------------------- + + +async def test_non_string_inbound_request_state_is_rejected_with_the_frozen_error() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a structurally invalid + (non-string) `requestState` placed raw in the params fails at the boundary — before + model validation — with the frozen shape; a stateless request still reaches the + handler.""" + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: + calls.append(params.name) + return CallToolResult(content=[TextContent(text="ran")]) + + server = Server("srv", on_call_tool=call_tool) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + + async with connected_runner(server) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": 123}) + assert calls == [] + result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}}) + + _assert_frozen_rejection(exc) + assert result["content"][0]["text"] == "ran" + assert calls == ["t"] + + +@pytest.mark.parametrize( + "security", + [ + pytest.param(RequestStateSecurity(keys=[_KEY]), id="configured"), + pytest.param(None, id="unconfigured"), + ], +) +async def test_an_explicit_null_request_state_is_treated_as_absent(security: RequestStateSecurity | None) -> None: + """SDK-defined (spec-aligned): an explicit `"requestState": null` is the field's + absence — a fresh flow, not presented state. The reject-MUST governs PRESENTED state, + and stripping the field is already in any client's power, so the handler runs and + sees None — on a configured server and on an unconfigured boundary alike.""" + seen: list[str | None] = [] + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="ran")]) + + server = Server("srv", on_call_tool=call_tool) + server.middleware.append(RequestStateBoundary(security)) + + async with connected_runner(server) as (client, _): + result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": None}) + + assert result["content"][0]["text"] == "ran" + assert seen == [None] + + +# -- off-set methods: requestState has exactly three legal carriers --------------------- + + +async def test_inbound_request_state_on_a_non_mrtr_method_is_rejected_before_dispatch() -> None: + """Spec-aligned fail-closed: only tools/call, prompts/get, and resources/read may + carry `requestState`, so a custom (extension-style) method or any other spec method + presenting one is answered with the frozen rejection before any unseal or handler + dispatch — forged state can never be laundered through a method the claims check + does not cover.""" + calls: list[str] = [] + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> dict[str, Any]: + calls.append(params.request_state or "fresh") + return {"resultType": "complete"} + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + + async with connected_runner(server) as (client, _): + for method in ("example/mrtr", "tools/list"): + with pytest.raises(MCPError) as exc: + await client.send_raw_request(method, {"requestState": "FORGED-BY-CLIENT"}) + _assert_frozen_rejection(exc) + assert calls == [] + ok = await client.send_raw_request("example/mrtr", {}) # no state: dispatch is untouched + + assert ok == {"resultType": "complete"} + assert calls == ["fresh"] + + +async def test_outbound_request_state_on_a_non_mrtr_method_is_an_internal_error_with_logged_remediation( + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-aligned fail-closed: the spec restricts InputRequiredResult to the three MRTR + carriers, so a custom method minting `requestState` is a server bug — the wire gets a + bare internal error (never plaintext, never a sealed token) and the remediation goes + to the server log.""" + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: + return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="ext-secret-plaintext") + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + + async with connected_runner(server) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("example/mrtr", {}) + + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + errors = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.ERROR] + assert len(errors) == 1 + assert errors[0].getMessage() == snapshot( + "handler for example/mrtr returned an input_required result carrying requestState, but the spec " + "restricts InputRequiredResult to tools/call, prompts/get, and resources/read; extension " + "and custom methods must not mint requestState. Refusing to send it." + ) + assert "ext-secret-plaintext" not in caplog.text + + +async def test_an_off_set_input_required_result_without_state_passes_through_untouched() -> None: + """SDK-defined: an input_required-shaped result on a non-MRTR method that mints no + `requestState` is not this module's concern — it crosses the boundary unmodified.""" + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: + return InputRequiredResult(input_requests={"confirm": _ask("?")}) + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + + async with connected_runner(server) as (client, _): + result = await client.send_raw_request("example/mrtr", {}) + + assert result["resultType"] == "input_required" + assert "confirm" in result["inputRequests"] + assert "requestState" not in result + + +# -- custom codec: deny on error ------------------------------------------------------- + + +async def test_a_codec_that_raises_unexpectedly_fails_closed_with_the_frozen_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated fail-safe (basic/patterns/mrtr server requirement 5): a buggy custom + codec denies on error — the wire gets the frozen rejection while the traceback stays + in the server log.""" + + class ExplodingCodec: + def seal(self, payload: bytes) -> str: + return "opaque-token" + + def unseal(self, token: str) -> bytes: + raise RuntimeError("codec exploded") + + mcp, seen = _manual_server(RequestStateSecurity(codec=ExplodingCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + assert token == "opaque-token" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + # The exact-match frozen assertions also prove the exception text + # never reached the wire. + _assert_frozen_rejection(exc) + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_a_codec_reject_reason_reaches_the_log_but_never_the_wire( + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5) plus the SDK log + contract: an `InvalidRequestState` reason from a custom codec is logged server-side + while the wire stays the frozen shape.""" + + class RefusingCodec: + def seal(self, payload: bytes) -> str: + return "opaque-token" + + def unseal(self, token: str) -> bytes: + raise InvalidRequestState("boom") + + mcp, seen = _manual_server(RequestStateSecurity(codec=RefusingCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + # Exact-match frozen assertions prove "boom" is not on the wire. + _assert_frozen_rejection(exc) + + assert "boom" in caplog.text + assert seen == [] + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param("not a claims envelope", id="not-json"), + pytest.param(json.dumps({"v": 1, "iat": 1, "exp": 2}), id="json-missing-claims"), + pytest.param(json.dumps({"v": 2, "iat": 1, "exp": 2, "s": "x"}), id="json-wrong-envelope-version"), + pytest.param(json.dumps({"v": 1, "iat": 1, "exp": 2, "s": 7}), id="json-non-string-state"), + ], +) +async def test_codec_authenticated_bytes_that_are_not_a_claims_envelope_are_rejected(payload: str) -> None: + """SDK-defined (claims enforcement for every codec): bytes a codec vouches for are + still nothing until they parse as the SDK's claims envelope — non-JSON payloads and + well-formed JSON of the wrong shape both collapse to the frozen rejection before the + handler runs (crafted via a passthrough codec; the built-in AEAD only ever + authenticates envelopes it sealed itself).""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=None)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, payload) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_a_forged_principal_claim_that_is_not_base64_is_rejected() -> None: + """SDK-defined (principal binding): a `p` claim that does not decode as base64 can + never match any principal, so the round collapses to the frozen rejection even + inside a token the codec authenticated (crafted via a passthrough codec; the + built-in AEAD makes the claim untouchable).""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + claims = json.loads(token) # passthrough codec: the token IS the envelope JSON + claims["p"] = "A" # a single base64 char can never pad to a valid quantum + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, json.dumps(claims)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +@pytest.mark.parametrize("forged", [pytest.param(7, id="int"), pytest.param({"x": 1}, id="object")]) +async def test_a_non_string_principal_claim_is_rejected_with_the_frozen_error(forged: Any) -> None: + """SDK-defined (principal binding): a non-string `p` claim inside a validly-sealed + envelope (possible only through a weak custom codec) collapses to the frozen + rejection — it can never raise past `_reject` and leak exception text onto the + wire.""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + claims = json.loads(token) # passthrough codec: the token IS the envelope JSON + claims["p"] = forged + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, json.dumps(claims)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +# -- log secrecy and the cause-invariant wire error ------------------------------------ + + +async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5) plus the SDK log + contract: tampered, expired, and rebound echoes produce byte-identical wire errors + (no failure oracle), the real reasons are logged at WARNING, and no log record ever + carries the token, the plaintext state, or the principal.""" + plaintext = "secret-plaintext-state-1f9b" + principal = "principal-alice-7c3d" + mcp, seen = _manual_server( + RequestStateSecurity(keys=[_KEY], ttl=_TTL, bind_principal=lambda ctx: principal), state=plaintext + ) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as tampered: + await _retry(client, "deploy", {"env": "prod"}, _tamper(token)) + clock.now = _T0 + _TTL + 1 + with pytest.raises(MCPError) as expired: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + with pytest.raises(MCPError) as rebound: + await _retry(client, "deploy", {"env": "staging"}, token) + _assert_frozen_rejection(tampered) + + shapes = [(e.value.error.code, e.value.error.message, e.value.error.data) for e in (tampered, expired, rebound)] + assert shapes[0] == shapes[1] == shapes[2] + assert seen == [] + + reject_logs = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.WARNING] + assert len(reject_logs) == 3 # the real reasons ARE logged... + for record in caplog.records: # ...but never the secrets + message = record.getMessage() + assert token not in message + assert plaintext not in message + assert principal not in message + + +# -- pass-through inertness ------------------------------------------------------------ + + +async def test_a_complete_result_crosses_the_boundary_untouched() -> None: + """SDK-defined: the outbound seam keys off `resultType` — a complete tools/call wire + result passes through as the identical object.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + complete: dict[str, Any] = {"resultType": "complete", "content": []} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return complete + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "t", "arguments": {}}, + ) + + assert await boundary(ctx, call_next) is complete + + +async def test_input_required_without_request_state_is_untouched() -> None: + """SDK-defined: sealing keys off the `requestState` field — an `input_required` + result that asks without minting state crosses the boundary unmodified, and the + response-only retry completes.""" + seen: list[str | None] = [] + mcp = MCPServer("stateless-ask", request_state_security=RequestStateSecurity(keys=[_KEY])) + + @mcp.tool() + async def ask(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Sure?")}) + seen.append(ctx.request_state) + return "done" + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("ask", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is None + second = await client.session.call_tool( + "ask", {}, input_responses={"confirm": _accept()}, allow_input_required=True + ) + + assert isinstance(second, CallToolResult) + assert seen == [None] + + +async def test_an_input_required_mapping_with_a_non_string_state_is_not_sealed() -> None: + """SDK-defined: only a middleware short-circuiting below the boundary can put a + non-string `requestState` in a wire mapping (the spec path validates the field as a + string); that value is not state this module minted or seals, so the result crosses + unchanged.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + malformed: dict[str, Any] = {"resultType": "input_required", "inputRequests": {}, "requestState": 7} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return malformed + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "t", "arguments": {}}, + ) + + assert await boundary(ctx, call_next) is malformed + + +async def test_a_notification_crosses_the_boundary_unharmed() -> None: + """SDK-defined: the boundary is inert for notifications — the context reaches + `call_next` as the identical object and the None result comes back unchanged.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + forwarded: list[ServerRequestContext[Any, Any]] = [] + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + forwarded.append(ctx) + return None + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="notifications/progress", + params={"progressToken": "p", "progress": 1}, + ) + + assert await boundary(ctx, call_next) is None + assert len(forwarded) == 1 + assert forwarded[0] is ctx + + +async def test_a_non_mrtr_method_with_no_params_is_untouched() -> None: + """SDK-defined: methods outside the MRTR trio pass the boundary inert — `tools/list` + with absent params is forwarded and its result returned identically.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + listing: dict[str, Any] = {"tools": [], "resultType": "complete"} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return listing + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/list", + params=None, + ) + + assert await boundary(ctx, call_next) is listing + + +# -- direct chain invocation: the model-path seal -------------------------------------- + + +async def test_a_short_circuited_input_required_model_is_sealed_via_the_model_path() -> None: + """SDK-defined: a middleware below the boundary that short-circuits with an + `InputRequiredResult` MODEL (not the serialized wire dict) still has its state + sealed — the boundary returns a copy carrying the sealed token, requests intact.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + interim = InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="model-plaintext") + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return interim + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "shortcut", "arguments": {}}, + ) + + result = await boundary(ctx, call_next) + + assert isinstance(result, InputRequiredResult) + assert result.input_requests == interim.input_requests + assert result.request_state is not None + assert result.request_state != "model-plaintext" + assert result.request_state.startswith("v1.") + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(result.request_state)) + assert (claims["m"], claims["t"], claims["s"]) == ("tools/call", "shortcut", "model-plaintext") + assert interim.request_state == "model-plaintext" # sealed on a copy; the original is untouched diff --git a/tests/server/test_request_state_gate.py b/tests/server/test_request_state_gate.py new file mode 100644 index 000000000..f5f318bff --- /dev/null +++ b/tests/server/test_request_state_gate.py @@ -0,0 +1,403 @@ +"""Startup gate for `request_state_security=` on MCP-server registration funnels +(`mcp.server.request_state` + the `MCPServer` wiring). + +Every test here is synchronous registration-time behavior: no Client, no +connection, no event loop. The gate is SDK-defined product policy, deliberately +stricter than the spec's conditional MUST (basic/patterns/mrtr, server +requirements 4-5 apply only when state influences authorization, resource +access, or business logic): the SDK cannot see what authors put in their state, +so every MRTR-capable registration must pick a `RequestStateSecurity` posture +up front, before any client can connect. +""" + +from typing import Annotated, Any + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolRequestParams, CallToolResult, InputRequiredResult + +from mcp.server import MCPServer, Server, ServerRequestContext +from mcp.server.extension import Extension, ToolBinding +from mcp.server.mcpserver import Context, Resolve +from mcp.server.mcpserver.prompts import Prompt +from mcp.server.mcpserver.tools import Tool +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity + +# Registration fixtures. Only their signatures are inspected at registration; none +# is ever called, so each body is a bare `...` (a constant statement the compiler +# eliminates - nothing for coverage to miss, and pyright treats them as stubs). + + +# Resolver for `Resolve(...)` markers: +async def _provide_login(ctx: Context) -> str: ... + + +# Resolver-driven tool (RESOLVER capability): +async def _deploy(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str: ... + + +# Manual-MRTR tool, prompt, and resource template (DECLARED_MANUAL capability): +async def _confirm_deploy(target: str) -> str | InputRequiredResult: ... + + +async def _briefing(topic: str) -> str | InputRequiredResult: ... + + +async def _record(id: str) -> str | InputRequiredResult: ... + + +# MRTR-free tool, prompt, static resource, and resource template: +async def _plain_tool(x: int) -> str: ... + + +async def _plain_prompt() -> str: ... + + +async def _plain_static() -> str: ... + + +async def _plain_template(id: str) -> str: ... + + +def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> None: + """SDK-defined product bar (stricter than the spec's conditional MUST, mrtr server + reqs 4-5): a `Resolve(...)` tool mints requestState, so registering it on a server + constructed without `request_state_security=` raises at the `@mcp.tool()` call with + the full teaching text.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError) as excinfo: + mcp.tool(name="deploy")(_deploy) + + assert str(excinfo.value) == snapshot("""\ +Tool 'deploy' uses Resolve(...) parameters, so this server mints a +requestState that round-trips through the client. The MCP spec requires that state +to be integrity-protected, and rejected when verification fails, whenever it can +influence authorization, resource access, or business logic. Configure protection: + + MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) + One or more shared secret keys (>= 32 bytes each). Required when a retry + can reach a different instance (multi-worker or load-balanced HTTP). + keys[0] seals, every key verifies; rotation is + [old, new] -> [new, old] -> [new], each phase fully rolled out first. + + MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) + A key generated at process start. Single-process deployments only + (stdio, one HTTP worker): state minted before a restart, or by another + instance, is rejected and the client must restart the flow. + + MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) + No protection. Only valid when tampering can cause nothing worse than a + failed request - not available for Resolve(...) tools, whose state + carries elicited answers. + +Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ +""") + + +def test_constructor_supplied_resolver_tool_bypasses_add_tool_but_is_still_rejected() -> None: + """SDK-defined: `MCPServer(tools=[...])` inserts Tool objects directly into the + ToolManager without going through `add_tool`, so `__init__` must re-scan and reject + an unprotected resolver tool at construction, naming it.""" + tool = Tool.from_function(_deploy, name="deploy") + + with pytest.raises(ValueError) as excinfo: + MCPServer("gate", tools=[tool]) + + assert "deploy" in str(excinfo.value) + + +def test_constructor_supplied_declared_manual_tool_is_rejected() -> None: + """SDK-defined: the constructor scan also derives DECLARED_MANUAL — a hand-supplied + Tool whose function declares an InputRequiredResult return is rejected at + `MCPServer(tools=[...])`, naming it.""" + with pytest.raises(ValueError) as excinfo: + MCPServer("gate", tools=[Tool.from_function(_confirm_deploy, name="confirm_deploy")]) + + assert "confirm_deploy" in str(excinfo.value) + assert "declares an InputRequiredResult return" in str(excinfo.value) + + +def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: + """SDK-defined: the constructor scan judges a hand-built Tool by its stored + `resolved_params` — the authority that actually drives resolution at call time — + not by re-inspecting `fn`, which a hand-built Tool may carry without any resolver + annotations.""" + tool = Tool.from_function(_deploy, name="deploy").model_copy(update={"fn": _plain_tool}) + + with pytest.raises(ValueError) as excinfo: + MCPServer("gate", tools=[tool]) + + assert "uses Resolve(...) parameters" in str(excinfo.value) + + +def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: + """SDK-defined: the decorator gate stands aside for a Resolve+InputRequiredResult + combination because `Tool.from_function` rejects it with its own error; a hand-built + Tool has no such backstop, so the constructor scan gates the combo as RESOLVER + (stored `resolved_params` decide) instead of silently admitting it.""" + tool = Tool.from_function(_deploy, name="combo").model_copy(update={"fn": _confirm_deploy}) + + with pytest.raises(ValueError) as excinfo: + MCPServer("gate", tools=[tool]) + + assert "uses Resolve(...) parameters" in str(excinfo.value) + + +def test_declared_manual_tool_without_security_is_rejected_naming_the_declared_return() -> None: + """SDK-defined: a tool annotated `-> str | InputRequiredResult` (manual MRTR, no + Resolve) also mints requestState, so unconfigured registration raises with the + DECLARED_MANUAL variant text naming the declared return.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError) as excinfo: + mcp.tool(name="confirm_deploy")(_confirm_deploy) + + assert str(excinfo.value) == snapshot("""\ +Tool 'confirm_deploy' declares an InputRequiredResult return, so this server mints a +requestState that round-trips through the client. The MCP spec requires that state +to be integrity-protected, and rejected when verification fails, whenever it can +influence authorization, resource access, or business logic. Configure protection: + + MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) + One or more shared secret keys (>= 32 bytes each). Required when a retry + can reach a different instance (multi-worker or load-balanced HTTP). + keys[0] seals, every key verifies; rotation is + [old, new] -> [new, old] -> [new], each phase fully rolled out first. + + MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) + A key generated at process start. Single-process deployments only + (stdio, one HTTP worker): state minted before a restart, or by another + instance, is rejected and the client must restart the flow. + + MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) + No protection. Only valid when tampering can cause nothing worse than a + failed request - not available for Resolve(...) tools, whose state + carries elicited answers. + +Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ +""") + + +def test_declared_manual_prompt_without_security_is_rejected_at_the_decorator_call() -> None: + """SDK-defined: prompts/get is an MRTR carrier too, so a prompt function declaring + `-> str | InputRequiredResult` is rejected at `@mcp.prompt()` on an unconfigured + server.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError) as excinfo: + mcp.prompt(name="briefing")(_briefing) + + assert str(excinfo.value) == snapshot("""\ +Prompt 'briefing' declares an InputRequiredResult return, so this server mints a +requestState that round-trips through the client. The MCP spec requires that state +to be integrity-protected, and rejected when verification fails, whenever it can +influence authorization, resource access, or business logic. Configure protection: + + MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) + One or more shared secret keys (>= 32 bytes each). Required when a retry + can reach a different instance (multi-worker or load-balanced HTTP). + keys[0] seals, every key verifies; rotation is + [old, new] -> [new, old] -> [new], each phase fully rolled out first. + + MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) + A key generated at process start. Single-process deployments only + (stdio, one HTTP worker): state minted before a restart, or by another + instance, is rejected and the client must restart the flow. + + MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) + No protection. Only valid when tampering can cause nothing worse than a + failed request - not available for Resolve(...) tools, whose state + carries elicited answers. + +Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ +""") + + +def test_declared_manual_prompt_via_add_prompt_is_rejected_the_same_way() -> None: + """SDK-defined: `add_prompt(Prompt.from_function(...))` is the same funnel the + decorator uses, so it trips the same gate and names the prompt.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError) as excinfo: + mcp.add_prompt(Prompt.from_function(_briefing, name="briefing")) + + assert "briefing" in str(excinfo.value) + + +def test_declared_manual_resource_template_without_security_is_rejected_at_the_decorator_call() -> None: + """SDK-defined: resources/read is an MRTR carrier for templates, so a template + function declaring `-> str | InputRequiredResult` is rejected at + `@mcp.resource("data://{id}")` on an unconfigured server.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError) as excinfo: + mcp.resource("data://{id}")(_record) + + assert str(excinfo.value) == snapshot("""\ +Resource template 'data://{id}' declares an InputRequiredResult return, so this server mints a +requestState that round-trips through the client. The MCP spec requires that state +to be integrity-protected, and rejected when verification fails, whenever it can +influence authorization, resource access, or business logic. Configure protection: + + MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) + One or more shared secret keys (>= 32 bytes each). Required when a retry + can reach a different instance (multi-worker or load-balanced HTTP). + keys[0] seals, every key verifies; rotation is + [old, new] -> [new, old] -> [new], each phase fully rolled out first. + + MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) + A key generated at process start. Single-process deployments only + (stdio, one HTTP worker): state minted before a restart, or by another + instance, is rejected and the client must restart the flow. + + MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) + No protection. Only valid when tampering can cause nothing worse than a + failed request - not available for Resolve(...) tools, whose state + carries elicited answers. + +Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ +""") + + +def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> None: + """SDK-defined: with `request_state_security=` supplied, the exact registrations + the gate rejects all succeed across every funnel (constructor `tools=`, tool and + prompt and resource decorators, `add_prompt`).""" + mcp = MCPServer( + "gate", + request_state_security=RequestStateSecurity.ephemeral(), + tools=[Tool.from_function(_deploy, name="deploy")], + ) + mcp.tool(name="confirm_deploy")(_confirm_deploy) + mcp.prompt(name="briefing")(_briefing) + mcp.add_prompt(Prompt.from_function(_briefing, name="briefing_via_add")) + mcp.resource("data://{id}")(_record) + + assert mcp._tool_manager.get_tool("deploy") is not None + assert mcp._tool_manager.get_tool("confirm_deploy") is not None + assert mcp._prompt_manager.get_prompt("briefing") is not None + assert mcp._prompt_manager.get_prompt("briefing_via_add") is not None + assert [t.uri_template for t in mcp._resource_manager.list_templates()] == ["data://{id}"] + + +def test_unprotected_refuses_resolver_tools_at_registration() -> None: + """SDK-defined: `unprotected()` is not a lawful opt-out for `Resolve(...)` tools — + their state carries elicited answers, which are business inputs — so registration + still raises, with text pointing at `keys=`/`ephemeral()`.""" + mcp = MCPServer("gate", request_state_security=RequestStateSecurity.unprotected()) + + with pytest.raises(ValueError) as excinfo: + mcp.tool(name="deploy")(_deploy) + + assert str(excinfo.value) == snapshot("""\ +Tool 'deploy' uses Resolve(...) parameters, so this server mints a +requestState that round-trips through the client. The MCP spec requires that state +to be integrity-protected, and rejected when verification fails, whenever it can +influence authorization, resource access, or business logic. Configure protection: + + MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) + One or more shared secret keys (>= 32 bytes each). Required when a retry + can reach a different instance (multi-worker or load-balanced HTTP). + keys[0] seals, every key verifies; rotation is + [old, new] -> [new, old] -> [new], each phase fully rolled out first. + + MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) + A key generated at process start. Single-process deployments only + (stdio, one HTTP worker): state minted before a restart, or by another + instance, is rejected and the client must restart the flow. + + Resolve(...) tools cannot opt out: their requestState carries elicited + answers, which are business inputs. Use keys=[...] or .ephemeral(). + +Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ +""") + + +def test_unprotected_is_a_lawful_opt_out_for_declared_manual_tools() -> None: + """SDK-defined: a manual `-> str | InputRequiredResult` flow may hold state the + spec's exception covers (tampering can cause nothing worse than request failure), + so `unprotected()` lets it register; the author has explicitly accepted the risk.""" + mcp = MCPServer("gate", request_state_security=RequestStateSecurity.unprotected()) + + mcp.tool(name="confirm_deploy")(_confirm_deploy) + + assert mcp._tool_manager.get_tool("confirm_deploy") is not None + + +def test_mrtr_free_registrations_need_no_security_configuration() -> None: + """SDK-defined: the gate keys on MRTR capability, so plain tools (decorator and + constructor-supplied), prompts, and resources register on an unconfigured server + exactly as before — this pins the gate against over-firing.""" + mcp = MCPServer("gate", tools=[Tool.from_function(_plain_tool, name="ctor_plain_tool")]) + + mcp.tool(name="plain_tool")(_plain_tool) + mcp.prompt(name="plain_prompt")(_plain_prompt) + mcp.resource("data://static")(_plain_static) + mcp.resource("plain://{id}")(_plain_template) + + assert mcp._tool_manager.get_tool("ctor_plain_tool") is not None + assert mcp._tool_manager.get_tool("plain_tool") is not None + assert mcp._prompt_manager.get_prompt("plain_prompt") is not None + assert len(mcp._resource_manager.list_resources()) == 1 + assert len(mcp._resource_manager.list_templates()) == 1 + + +def test_security_with_zero_mrtr_registrations_is_legal_and_inert() -> None: + """SDK-defined: configuring `request_state_security=` on a server that registers + no MRTR-capable surface is legal — the policy sits inert rather than demanding + MRTR usage.""" + mcp = MCPServer("gate", request_state_security=RequestStateSecurity.ephemeral()) + + mcp.tool(name="plain_tool")(_plain_tool) + + assert mcp._tool_manager.get_tool("plain_tool") is not None + + +def test_lowlevel_server_has_no_gate_and_takes_the_boundary_as_ordinary_middleware() -> None: + """SDK-defined: the lowlevel tier cannot see MRTR capability (handlers are opaque + callables), so `Server` accepts an input_required-returning handler freely and + protection is explicit — appending a `RequestStateBoundary` to `Server.middleware` + grows the chain by one.""" + + # Handler fixture: lowlevel registration neither inspects nor runs it here. + async def call_tool( + ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: ... + + server = Server("lowlevel", on_call_tool=call_tool) + baseline = len(server.middleware) + + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral())) + + assert len(server.middleware) == baseline + 1 + + +def test_extension_contributed_resolver_tool_is_gated_through_add_tool() -> None: + """SDK-defined: extension tools register through `MCPServer.add_tool`, so an + extension whose `tools()` yields a `Resolve(...)` tool trips the gate when the + host server has no `request_state_security=`.""" + + class ResolverExt(Extension): + identifier = "com.example/resolver" + + def tools(self) -> list[ToolBinding]: + return [ToolBinding(fn=_deploy, kwargs={"name": "deploy"})] + + with pytest.raises(ValueError) as excinfo: + MCPServer("gate", extensions=[ResolverExt()]) + + assert "deploy" in str(excinfo.value) + + +def test_the_gate_fires_in_the_synchronous_registration_frame_not_at_first_request() -> None: + """SDK-defined: rejection happens at the registration call itself — this module + creates no Client, opens no connection, and runs no event loop — and a rejected + registration leaves the server usable for further registrations.""" + mcp = MCPServer("gate") + + with pytest.raises(ValueError): + mcp.tool(name="deploy")(_deploy) + + mcp.tool(name="plain_tool")(_plain_tool) + assert mcp._tool_manager.get_tool("plain_tool") is not None diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 342720c32..e796324a0 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -553,6 +553,23 @@ def test_cacheable_methods_mirror_the_cacheable_method_literal(): assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod)) +def test_input_required_methods_mirror_the_monolith_input_required_arms(): + """MRTR weld: the set derived from `MONOLITH_RESULTS` is exactly the spec's three + multi-round-trip carriers — the only methods whose results may be input_required.""" + assert methods.INPUT_REQUIRED_METHODS == frozenset({"prompts/get", "resources/read", "tools/call"}) + + +def test_is_input_required_matches_typed_and_wire_shapes(): + """The shared interim-result predicate: True for the `InputRequiredResult` model and + for a wire mapping tagged `resultType: "input_required"`; False for everything else.""" + assert methods.is_input_required(types.InputRequiredResult(request_state="s")) + assert methods.is_input_required({"resultType": "input_required", "inputRequests": {}}) + assert not methods.is_input_required({"resultType": "complete", "content": []}) + assert not methods.is_input_required({}) + assert not methods.is_input_required(types.CallToolResult(content=[])) + assert not methods.is_input_required(None) + + def test_minimal_request_bodies_parse_through_every_request_row(): for (method, version), surface_type in methods.CLIENT_REQUESTS.items(): parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type]) From d078a9dd2ba7afe4ea1d4e7cc7acdb1d32679ffd Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:20:32 +0000 Subject: [PATCH 2/8] Pin recorded resolver answers to the rendered question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolver state entries (now v2) record a digest of the exact rendered elicitation per outcome — accepts, declines, and cancels alike. A stored answer is honored only while the live question renders byte-identically; a redeploy that rewords a question or changes its schema re-asks instead of silently reusing a stale answer, and a recorded decline cannot suppress a reworded question. Cryptographic failure stays a wire error; a digest mismatch is a quiet re-ask, since the seal already proved the client honest and the server is what changed. --- src/mcp/server/mcpserver/resolve.py | 61 ++- tests/server/mcpserver/test_resolve.py | 562 ++++++++++++++++++++++--- 2 files changed, 546 insertions(+), 77 deletions(-) diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 9ff8dfeed..5dcafb4e1 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -28,6 +28,8 @@ from __future__ import annotations +import base64 +import hashlib import inspect import types import typing @@ -73,7 +75,7 @@ # `InputRequiredResult` rather than as a standalone server-to-client request. # Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added). _INPUT_REQUIRED_VERSION = "2026-07-28" -_STATE_VERSION = 1 +_STATE_VERSION = 2 # v2 adds per-entry question digests class Resolve: @@ -494,12 +496,19 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio if not res.input_required: return await res.context.elicit(elicit.message, elicit.schema) + # Every recorded outcome - accept, decline, AND cancel - is pinned to the exact + # question it answered: a decline of one wording must not suppress a reworded + # question that reuses the same wire key after a redeploy. The digest is + # computed once per question per round and shared by restore and persist. + q = _question_digest(elicit) + # A recorded outcome from a prior round is consulted only here, after the body # decided to ask, so a `request_state` entry can never stand in for a resolver's - # own computation. Re-validate it against the live `Elicit.schema`. A recorded - # outcome wins over a re-sent answer; an invalid entry self-deletes and falls - # through to the fresh answer (or to re-asking). - outcome = _restore_outcome(res, key, elicit.schema) + # own computation. It is honored only for the exact question being asked, and + # accept data is re-validated against the live `Elicit.schema`. A recorded + # outcome wins over a re-sent answer; a stale or invalid entry self-deletes and + # falls through to the fresh answer (or to re-asking). + outcome = _restore_outcome(res, key, elicit.schema, q) if outcome is not None: return outcome @@ -521,12 +530,12 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio ) from e # Persist the exact wire content that just passed validation - never the # model - so restoring next round revalidates the same bytes the client sent. - res.persist[key] = _StateEntry(action="accept", data=answer.content) + res.persist[key] = _StateEntry(action="accept", data=answer.content, q=q) return AcceptedElicitation(data=data) if answer.action == "decline": - res.persist[key] = _StateEntry(action="decline") + res.persist[key] = _StateEntry(action="decline", q=q) return DeclinedElicitation() - res.persist[key] = _StateEntry(action="cancel") + res.persist[key] = _StateEntry(action="cancel", q=q) return CancelledElicitation() @@ -595,6 +604,21 @@ class _StateEntry(BaseModel): action: Literal["accept", "decline", "cancel"] data: Any = None + q: str | None = None + """Digest of the exact rendered question this outcome answered.""" + + +def _question_digest(elicit: Elicit[Any]) -> str: + """Pin an outcome to the exact rendered question the client was shown. + + Computed over the rendered ElicitRequest params bytes - the same bytes the + client displayed - so a recorded outcome survives only as long as the + question is byte-identical. A redeploy that rewords the message or changes + the schema re-asks instead of silently reusing a stale answer. + """ + rendered = _elicit_request(elicit).params.model_dump_json(by_alias=True, exclude_none=True) + digest = hashlib.sha256(rendered.encode()).digest()[:16] + return base64.urlsafe_b64encode(digest).decode().rstrip("=") class _State(BaseModel): @@ -607,8 +631,11 @@ class _State(BaseModel): def _decode_state(request_state: str | None) -> dict[str, _StateEntry]: """Decode the per-call resolution progress from `request_state`. - `request_state` is client-trusted (integrity sealing is a follow-up); validate - it through `_State` and treat anything malformed as "no progress yet". + The string arrives boundary-authenticated (the middleware only forwards + plaintext this server minted), so anything malformed or version-mismatched + here is inner-format drift within the operator's own fleet - e.g. a rolling + upgrade - where treating it as "no progress yet" and re-asking is exactly + right. """ if not request_state: return {} @@ -642,12 +669,15 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat return _accepted(schema.model_validate(entry.data)) -def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None: +def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None: """Restore `key`'s recorded outcome from a prior round, or `None` when absent. - `request_state` is client-trusted, so an entry whose data fails validation gets - the `_decode_state` treatment - dropped as if no progress was recorded, so the - question is asked again - rather than surfacing a validation error. + An entry is honored only for the exact question being asked - `q` is the + live question's digest, precomputed by the caller: one pinned to a different + rendered question (the server reworded or reshaped it since the outcome was + recorded), or whose accepted data fails validation against the live + `schema`, is dropped as if no progress was recorded - so the question is + asked again - rather than surfacing an error. Carries the original decoded entry forward unchanged in `res.persist`: if a later resolver is still pending, the next round's `request_state` is built from @@ -657,6 +687,9 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> Eli entry = res.state.get(key) if entry is None: return None + if entry.q != q: + del res.state[key] + return None try: outcome = _outcome_from_state(entry, schema) except ValidationError: diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 571cefcb6..b8f6f79d9 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -3,7 +3,7 @@ import json from collections.abc import Callable from datetime import datetime -from typing import Annotated, Any, Literal, TypeVar +from typing import Annotated, Any, Literal, TypeVar, cast import anyio import pytest @@ -23,14 +23,18 @@ from mcp import Client, InputRequiredRoundsExceededError from mcp.client import ClientRequestContext +from mcp.server.context import ServerRequestContext from mcp.server.mcpserver import ( AcceptedElicitation, + AESGCMRequestStateCodec, CancelledElicitation, Context, DeclinedElicitation, Elicit, ElicitationResult, MCPServer, + RequestStateBoundary, + RequestStateSecurity, Resolve, ) from mcp.server.mcpserver.exceptions import InvalidSignature @@ -39,6 +43,7 @@ _decode_state, _encode_state, _outcome_from_state, + _question_digest, _resolver_key, _state_key, _StateEntry, @@ -126,9 +131,55 @@ def _answer_round( return responses +# A fixed key so tests can unseal the wire `request_state` a server minted (and +# seal crafted state a server will accept): the boundary's claims envelope +# carries the inner plaintext state as the "s" claim. Servers under test whose +# wire state a test reads or writes are constructed with +# `RequestStateSecurity(keys=[_PIN_KEY])`. +_PIN_KEY = b"0123456789abcdef0123456789abcdef" + + +def _unseal_inner(request_state: str | None) -> str: + """Unseal a wire `request_state` minted under `_PIN_KEY` into the inner plaintext state.""" + assert request_state is not None + claims = json.loads(AESGCMRequestStateCodec([_PIN_KEY]).unseal(request_state)) + inner = claims["s"] + assert isinstance(inner, str) + return inner + + +def _outcomes_on_the_wire(request_state: str | None) -> dict[str, Any]: + """Unseal a wire `request_state` minted under `_PIN_KEY` and return its outcomes.""" + return json.loads(_unseal_inner(request_state))["outcomes"] + + +def _sealed_state(inner: str, *, tool: str, args: dict[str, Any], audience: str) -> str: + """Seal a hand-built inner state exactly as the boundary does for a `tools/call` retry. + + Goes through the production `RequestStateBoundary._seal` so the claims + envelope cannot drift from what the server-side unseal verifies. The claims + bind method + tool + arguments + audience (and no principal: the in-memory + transport is unauthenticated, so both seal and unseal derive None), so the + test must then call exactly `tool` with exactly `args` on the MCPServer + named `audience` (the server name is the boundary's default audience). + """ + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": tool, "arguments": args}, + ) + return RequestStateBoundary(RequestStateSecurity(keys=[_PIN_KEY]), default_audience=audience)._seal(ctx, inner) + + +def _wire_key(fn: Callable[..., Any]) -> str: + return f"{fn.__module__}:{fn.__qualname__}" + + @pytest.mark.anyio async def test_resolver_returns_value_directly_without_eliciting(): - mcp = MCPServer(name="Direct") + mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: username = (ctx.headers or {}).get("x-github-user") @@ -149,7 +200,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_elicits_and_injects_unwrapped_model_on_accept(): - mcp = MCPServer(name="Accept") + mcp = MCPServer(name="Accept", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -164,7 +215,7 @@ async def whoami(login: Annotated[Login, Resolve(login)]) -> str: @pytest.mark.anyio async def test_consumer_receives_result_union_and_branches(): - mcp = MCPServer(name="Union") + mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -183,7 +234,7 @@ async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> @pytest.mark.anyio async def test_decline_reaches_union_consumer_without_aborting(): - mcp = MCPServer(name="UnionDecline") + mcp = MCPServer(name="UnionDecline", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -202,7 +253,7 @@ async def whoami( @pytest.mark.anyio async def test_decline_aborts_when_consumer_wants_unwrapped(): - mcp = MCPServer(name="UnwrappedDecline") + mcp = MCPServer(name="UnwrappedDecline", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -220,7 +271,7 @@ async def whoami(login: Annotated[Login, Resolve(login)]) -> str: @pytest.mark.anyio async def test_nested_resolver_sees_dependency_and_tool_args(): - mcp = MCPServer(name="Nested") + mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -251,7 +302,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_resolver_runs_once_for_two_consumers(): - mcp = MCPServer(name="ExactlyOnce") + mcp = MCPServer(name="ExactlyOnce", request_state_security=RequestStateSecurity.ephemeral()) elicit_count = 0 async def login(ctx: Context) -> Login | Elicit[Login]: @@ -281,7 +332,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_sync_resolver(): - mcp = MCPServer(name="Sync") + mcp = MCPServer(name="Sync", request_state_security=RequestStateSecurity.ephemeral()) def login(ctx: Context) -> Login: return Login(username="sync-user") @@ -428,7 +479,7 @@ async def tool(login: Annotated[Login, Resolve(BadResolver())]) -> str: @pytest.mark.anyio async def test_by_name_resolver_param_uses_aliased_tool_arg(): - mcp = MCPServer(name="Aliased") + mcp = MCPServer(name="Aliased", request_state_security=RequestStateSecurity.ephemeral()) # `schema` collides with a BaseModel attribute, so func_metadata aliases the field; # the runtime kwarg key is the alias, which is what a by-name resolver must match. @@ -448,7 +499,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_may_return_non_basemodel_value(): - mcp = MCPServer(name="NonModel") + mcp = MCPServer(name="NonModel", request_state_security=RequestStateSecurity.ephemeral()) async def get_token(ctx: Context) -> str: return "secret-token" @@ -466,7 +517,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_accepts_optional_context_annotation(): - mcp = MCPServer(name="OptionalContext") + mcp = MCPServer(name="OptionalContext", request_state_security=RequestStateSecurity.ephemeral()) async def whoami(ctx: Context | None) -> str: assert ctx is not None @@ -485,7 +536,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_bound_method_resolver_runs_once_across_references(): - mcp = MCPServer(name="BoundMethod") + mcp = MCPServer(name="BoundMethod", request_state_security=RequestStateSecurity.ephemeral()) calls = 0 class Service: @@ -537,7 +588,7 @@ async def tool(value: Annotated[Login, Resolve(service.a)]) -> str: @pytest.mark.anyio async def test_resolver_and_body_see_the_same_validated_default(): - mcp = MCPServer(name="DefaultFactory") + mcp = MCPServer(name="DefaultFactory", request_state_security=RequestStateSecurity.ephemeral()) counter = {"n": 0} def next_id() -> int: @@ -590,7 +641,7 @@ def fn() -> None: ... # pragma: no cover def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]: """The `delete_folder` example from docs/migration.md, wired to an in-memory fs.""" - mcp = MCPServer(name="files") + mcp = MCPServer(name="files", request_state_security=RequestStateSecurity.ephemeral()) fs: dict[str, list[str]] = {} async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: @@ -723,7 +774,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_input_required_asks_each_question_once_while_bodies_rerun(): - mcp = MCPServer(name="ExactlyOnceMRTR") + mcp = MCPServer(name="ExactlyOnceMRTR", request_state_security=RequestStateSecurity.ephemeral()) counts = {"login": 0, "confirm": 0} async def login(ctx: Context) -> Login | Elicit[Login]: @@ -768,7 +819,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_input_required_batches_independent_elicits_in_one_round(): - mcp = MCPServer(name="BatchedMRTR") + mcp = MCPServer(name="BatchedMRTR", request_state_security=RequestStateSecurity.ephemeral()) async def ask_name(ctx: Context) -> Elicit[Login]: return Elicit("Name?", Login) @@ -812,7 +863,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: async def test_auto_driver_answers_independent_questions_in_a_single_round(): # The pure `count_round` resolver is never persisted in `request_state`, so it # re-runs on every round: its run count is the number of rounds the call took. - mcp = MCPServer(name="AutoBatch") + mcp = MCPServer(name="AutoBatch", request_state_security=RequestStateSecurity.ephemeral()) rounds = 0 async def count_round(ctx: Context) -> int: @@ -907,7 +958,7 @@ def test_check_elicit_return_allows_one_arm_and_rejects_two(): @pytest.mark.anyio async def test_non_elicitation_response_raises(): - mcp = MCPServer(name="WrongResponse") + mcp = MCPServer(name="WrongResponse", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("Name?", Login) @@ -942,7 +993,7 @@ async def test_direct_call_tool_with_non_eliciting_resolver(): # `MCPServer.call_tool()` called directly builds a Context with no request, so # `ctx.protocol_version` is None. A tool whose resolvers never elicit must still # work there (regression: it used to raise "Context is not available"). - mcp = MCPServer(name="Direct") + mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral()) async def whoami(ctx: Context) -> Login: return Login(username="direct") @@ -959,7 +1010,7 @@ async def tool(login: Annotated[Login, Resolve(whoami)]) -> str: @pytest.mark.anyio async def test_two_instances_of_one_method_do_not_collide(): - mcp = MCPServer(name="Instances") + mcp = MCPServer(name="Instances", request_state_security=RequestStateSecurity.ephemeral()) class Service: def __init__(self, name: str) -> None: @@ -985,7 +1036,7 @@ async def both( @pytest.mark.anyio async def test_non_serializable_sibling_resolver_does_not_break_rounds(): - mcp = MCPServer(name="NonSerializable") + mcp = MCPServer(name="NonSerializable", request_state_security=RequestStateSecurity.ephemeral()) async def clock(ctx: Context) -> datetime: return datetime(2026, 1, 1) @@ -1013,7 +1064,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_bare_elicit_dependency_restored_as_model(): # A `-> Elicit[Login]` (bare, no union) resolver feeds a dependent resolver. After # the round-trip the dependency must come back as a Login model, not a raw dict. - mcp = MCPServer(name="BareElicitDep") + mcp = MCPServer(name="BareElicitDep", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1045,7 +1096,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["legacy", "auto"]): # Both transports must agree: mode="legacy" elicits synchronously mid-call, # mode="auto" rides the 2026-07-28 input_required loop. - mcp = MCPServer(name="AcceptNoContent") + mcp = MCPServer(name="AcceptNoContent", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1069,7 +1120,7 @@ async def test_eliciting_tool_without_client_capability_is_a_protocol_error(): # The server must not send an `input_requests` entry the client has not declared # capability for: with no `elicitation` declared (no callback), the call fails as # a -32021 protocol error, not a CallToolResult execution failure. - mcp = MCPServer(name="NoElicitationCapability") + mcp = MCPServer(name="NoElicitationCapability", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1088,7 +1139,7 @@ async def tool(login: Annotated[Login, Resolve(ask)]) -> str: @pytest.mark.anyio async def test_independent_nested_deps_batch_into_one_round(): - mcp = MCPServer(name="NestedBatch") + mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral()) async def ask_a(ctx: Context) -> Elicit[Login]: return Elicit("A name?", Login) @@ -1135,7 +1186,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: async def test_deep_chain_keeps_early_answers_across_rounds(): # A 4-round dependency chain where an early answer (A) must survive in # request_state while later resolvers are asked. It must be asked exactly once. - mcp = MCPServer(name="DeepChain") + mcp = MCPServer(name="DeepChain", request_state_security=RequestStateSecurity.ephemeral()) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("A name?", Login) @@ -1177,7 +1228,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_factory_closures_get_distinct_wire_keys(): # Two resolvers from one factory share module:qualname; they must still get # distinct questions and their own values (regression: they collided on the wire). - mcp = MCPServer(name="FactoryClosures") + mcp = MCPServer(name="FactoryClosures", request_state_security=RequestStateSecurity.ephemeral()) def make(label: str): async def resolver(ctx: Context) -> Elicit[Login]: @@ -1222,7 +1273,7 @@ async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model(): # round flow, must still come back as a Login model (not a raw dict): restore # validates against the live `Elicit.schema` the body produced, not the lying # annotation, so a dependent resolver/tool can use its attributes. - mcp = MCPServer(name="LyingAnnotation") + mcp = MCPServer(name="LyingAnnotation", request_state_security=RequestStateSecurity.ephemeral()) # Annotated without an `Elicit[T]` return arm; the body asks anyway. async def login(ctx: Context) -> object: @@ -1274,7 +1325,7 @@ async def test_declined_outcome_persists_in_request_state_and_is_not_reasked(): # A decline is recorded in `request_state` just like an accept: RB elicits only # after seeing RA's decline, so RA's outcome must survive into the round that # answers RB without RA being asked again. - mcp = MCPServer(name="DeclinePersists") + mcp = MCPServer(name="DeclinePersists", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1308,7 +1359,7 @@ async def act( assert second.input_requests is not None (rb_key,) = second.input_requests # only RB's question; RA is not re-asked assert rb_key != ra_key - assert _decode_state(second.request_state)[ra_key].action == "decline" + assert _outcomes_on_the_wire(second.request_state)[ra_key]["action"] == "decline" final = await client.session.call_tool( "act", @@ -1325,9 +1376,9 @@ async def act( @pytest.mark.anyio async def test_unknown_response_keys_and_ghost_state_entries_are_ignored(): # `input_responses` keys the server never asked for and `request_state` outcome - # entries matching no resolver are tolerated (both are client-supplied), and the - # ghost state entry is not echoed into any later round's `request_state`. - mcp = MCPServer(name="GhostKeys") + # entries matching no resolver are tolerated, and the ghost state entry is not + # echoed into any later round's `request_state`. + mcp = MCPServer(name="GhostKeys", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1349,8 +1400,14 @@ async def act( assert first.request_state is not None (ra_key,) = first.input_requests - spliced = json.loads(first.request_state) - spliced["outcomes"]["ghost"] = {"action": "accept", "data": {"username": "spooky"}} + spliced = json.loads(_unseal_inner(first.request_state)) + # A well-formed v2 entry (question digest included) under a key matching + # no resolver: it must be dropped for being unknown, not as malformed. + spliced["outcomes"]["ghost"] = { + "action": "accept", + "data": {"username": "spooky"}, + "q": _question_digest(Elicit("user?", Login)), + } second = await client.session.call_tool( "act", {}, @@ -1358,13 +1415,13 @@ async def act( ra_key: ElicitResult(action="accept", content={"username": "octocat"}), "ghost": ElicitResult(action="accept", content={"username": "spooky"}), }, - request_state=json.dumps(spliced), + request_state=_sealed_state(json.dumps(spliced), tool="act", args={}, audience="GhostKeys"), allow_input_required=True, ) assert isinstance(second, InputRequiredResult) assert second.input_requests is not None (rb_key,) = second.input_requests - outcomes = _decode_state(second.request_state) + outcomes = _outcomes_on_the_wire(second.request_state) assert ra_key in outcomes assert "ghost" not in outcomes # the spliced entry is dropped, not carried onward @@ -1389,10 +1446,11 @@ async def act( ], ) async def test_forged_state_entry_failing_the_schema_is_reasked_not_an_error(forged_data: str | dict[str, bool]): - # `request_state` is client-trusted JSON: an accept entry whose data does not - # validate against the resolver's schema reads as no recorded progress, so the - # question is asked again (not an error) and a proper answer completes the call. - mcp = MCPServer(name="ForgedState") + # Even boundary-authenticated state is not schema-trusted: an accept entry + # whose data does not validate against the resolver's schema reads as no + # recorded progress, so the question is asked again (not an error) and a + # proper answer completes the call. + mcp = MCPServer(name="ForgedState", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1408,15 +1466,24 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: assert first.request_state is not None (key,) = first.input_requests - forged = json.loads(first.request_state) - forged["outcomes"][key] = {"action": "accept", "data": forged_data} + forged = json.loads(_unseal_inner(first.request_state)) + # The digest matches the live question, so the forged entry survives the + # question-pinning gate and stands or falls on schema validation alone. + forged["outcomes"][key] = { + "action": "accept", + "data": forged_data, + "q": _question_digest(Elicit("user?", Login)), + } second = await client.session.call_tool( - "whoami", {}, request_state=json.dumps(forged), allow_input_required=True + "whoami", + {}, + request_state=_sealed_state(json.dumps(forged), tool="whoami", args={}, audience="ForgedState"), + allow_input_required=True, ) assert isinstance(second, InputRequiredResult) # re-asked, not an error assert second.input_requests is not None assert set(second.input_requests) == {key} - assert _decode_state(second.request_state) == {} # the forged entry is dropped + assert _outcomes_on_the_wire(second.request_state) == {} # the forged entry is dropped final = await client.session.call_tool( "whoami", @@ -1436,7 +1503,7 @@ async def test_schema_mismatched_fresh_answer_fails_the_call_without_pydantic_le # An accepted answer whose content fails the requested schema fails the call # with the framework's own message on both transports; pydantic's error text # (which carries an "errors.pydantic.dev" link) must not leak to the client. - mcp = MCPServer(name="MismatchedAnswer") + mcp = MCPServer(name="MismatchedAnswer", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1464,7 +1531,7 @@ async def test_auto_driver_gives_up_when_the_chain_outlasts_its_round_budget(): # than the default `input_required_max_rounds`, so `client.call_tool` must raise # rather than loop on. The pure `count_leg` resolver is never persisted, so it # re-runs on every server leg: its final value is the exact number of legs. - mcp = MCPServer(name="TooDeep") + mcp = MCPServer(name="TooDeep", request_state_security=RequestStateSecurity.ephemeral()) legs = 0 async def count_leg(ctx: Context) -> int: @@ -1514,7 +1581,7 @@ async def test_aliased_elicitation_model_round_trips_through_request_state(): # the same validation the answer originally passed - aliases and all. A # re-derived (field-name) shape would fail validation on the round after # next, drop the stored answer, and re-ask the user forever. - mcp = MCPServer(name="AliasState") + mcp = MCPServer(name="AliasState", request_state_security=RequestStateSecurity.ephemeral()) async def who(ctx: Context) -> Elicit[Handle]: return Elicit("handle?", Handle) @@ -1566,7 +1633,7 @@ async def test_divergent_validation_and_serialization_aliases_round_trip(): # the validated model (which serializes under the *serialization* alias) would # produce data the schema's own validation rejects, dropping the stored answer # on the round after next and re-asking the user. - mcp = MCPServer(name="DivergentAliases") + mcp = MCPServer(name="DivergentAliases", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def who(ctx: Context) -> Elicit[Account]: return Elicit("account?", Account) @@ -1602,7 +1669,7 @@ async def act( (go_key,) = second.input_requests # only the dependent question; the stored answer holds assert go_key != who_key # The stored entry is the client's wire content, not a re-serialization of it. - assert _decode_state(second.request_state)[who_key].data == {"vUser": "octocat"} + assert _outcomes_on_the_wire(second.request_state)[who_key]["data"] == {"vUser": "octocat"} final = await client.session.call_tool( "act", @@ -1621,7 +1688,7 @@ async def test_state_entry_never_replaces_a_resolver_computed_value(): # `request_state` is client-echoed: an accept entry under a resolver's wire key # must only satisfy a question the resolver is actually asking, never stand in # for the body's own computation on a branch that does not ask. - mcp = MCPServer(name="StateVsBody") + mcp = MCPServer(name="StateVsBody", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) calls = {"decide": 0} async def decide(ctx: Context) -> Restock | Elicit[Restock]: @@ -1632,11 +1699,20 @@ async def decide(ctx: Context) -> Restock | Elicit[Restock]: async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: return str(restock.needed) - wire_key = f"{decide.__module__}:{decide.__qualname__}" - crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "accept", "data": {"needed": True}}}}) + # A decodable v2 entry; the digest's value cannot matter because the + # resolver computes without asking, so there is no live question to pin to. + # The property is that the entry is never consulted, not that it is dropped + # as malformed. + entry = {"action": "accept", "data": {"needed": True}, "q": _question_digest(Elicit("Restock?", Restock))} + crafted = json.dumps({"v": 2, "outcomes": {_wire_key(decide): entry}}) async with Client(mcp, elicitation_callback=_never) as client: - result = await client.session.call_tool("plan_restock", {}, request_state=crafted, allow_input_required=True) + result = await client.session.call_tool( + "plan_restock", + {}, + request_state=_sealed_state(crafted, tool="plan_restock", args={}, audience="StateVsBody"), + allow_input_required=True, + ) assert isinstance(result, CallToolResult) assert isinstance(result.content[0], TextContent) # The body ran and its computation won; the crafted entry was never consulted. @@ -1648,7 +1724,7 @@ async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: async def test_state_decline_entry_for_a_pure_resolver_is_ignored(): # A decline/cancel entry can only answer a question; a resolver with no Elicit # arm never asks one, so such an entry cannot suppress its computed value. - mcp = MCPServer(name="PureVsDecline") + mcp = MCPServer(name="PureVsDecline", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def lookup(ctx: Context) -> Login: return Login(username="server-side") @@ -1657,11 +1733,18 @@ async def lookup(ctx: Context) -> Login: async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str: return login.username - wire_key = f"{lookup.__module__}:{lookup.__qualname__}" - crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "decline"}}}) + # A decodable v2 entry with a plausible digest: `lookup` has no Elicit arm, + # so no value of `q` could make this decline answer a question it asks. + entry = {"action": "decline", "q": _question_digest(Elicit("user?", Login))} + crafted = json.dumps({"v": 2, "outcomes": {_wire_key(lookup): entry}}) async with Client(mcp, elicitation_callback=_never) as client: - result = await client.session.call_tool("whoami", {}, request_state=crafted, allow_input_required=True) + result = await client.session.call_tool( + "whoami", + {}, + request_state=_sealed_state(crafted, tool="whoami", args={}, audience="PureVsDecline"), + allow_input_required=True, + ) assert isinstance(result, CallToolResult) assert not result.is_error assert isinstance(result.content[0], TextContent) @@ -1673,7 +1756,7 @@ async def test_dynamic_schema_resolver_restores_across_rounds(): # `-> Elicit[BaseModel]` is the natural annotation for `create_model(...)` # schemas; the restored answer must validate against the live question's # schema, so the dynamic shape works across a multi-question chain. - mcp = MCPServer(name="DynamicSchema") + mcp = MCPServer(name="DynamicSchema", request_state_security=RequestStateSecurity.ephemeral()) dyn = create_model("Dyn", token=(str, ...)) async def first(ctx: Context) -> Elicit[BaseModel]: @@ -1729,7 +1812,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: def test_tool_combining_resolvers_with_input_required_return_is_rejected(annotation: Any): # A call has one input_responses/request_state channel: resolver elicitation # and a hand-rolled InputRequiredResult body cannot share it. - mcp = MCPServer(name="ChannelOwnership") + mcp = MCPServer(name="ChannelOwnership", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") # pragma: no cover - registration is rejected @@ -1755,7 +1838,7 @@ def test_unevaluable_alias_and_parameterized_generics_declare_no_arm(): # can see and must not break registration (the in-call guard still covers a # body that returns an InputRequiredResult anyway). A parameterized generic # return is never the InputRequiredResult class either. - mcp = MCPServer(name="RegistrationTolerance") + mcp = MCPServer(name="RegistrationTolerance", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") # pragma: no cover - only registration is exercised @@ -1778,7 +1861,7 @@ async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_er # The annotated form of this combination is rejected at registration; a body # that returns an InputRequiredResult without declaring it fails loudly at the # same boundary instead of silently fighting the resolvers for the channel. - mcp = MCPServer(name="DynamicChannelClash") + mcp = MCPServer(name="DynamicChannelClash", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") @@ -1792,3 +1875,356 @@ async def sneaky(login: Annotated[Login, Resolve(lookup)]): assert result.is_error assert isinstance(result.content[0], TextContent) assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text + + +def test_question_digest_pins_the_rendered_question(): + # The digest is computed over the rendered wire question, so it is stable for + # an identical Elicit and changes when the message or the schema changes. + digest = _question_digest(Elicit("Name?", Login)) + assert digest == _question_digest(Elicit("Name?", Login)) + assert digest != _question_digest(Elicit("Your name, please?", Login)) + assert digest != _question_digest(Elicit("Name?", Confirm)) + # A 16-byte sha256 prefix, base64url without padding. + assert len(digest) == 22 and "=" not in digest + + +def test_state_round_trips_question_digests_at_v2(): + # v2 entries carry the question digest for every action; encode-decode is the + # identity on them. A v1 payload (a not-yet-upgraded fleet member during a + # rolling deploy) decodes to "no progress yet" - a graceful re-ask, not an error. + entries = { + "a": _StateEntry(action="accept", data={"username": "octocat"}, q="qa"), + "b": _StateEntry(action="decline", q="qb"), + "c": _StateEntry(action="cancel", q="qc"), + } + encoded = _encode_state(entries) + assert json.loads(encoded)["v"] == 2 + assert _decode_state(encoded) == entries + v1 = json.dumps({"v": 1, "outcomes": {"a": {"action": "decline"}}}) + assert _decode_state(v1) == {} + + +@pytest.mark.anyio +async def test_restored_answer_with_matching_digest_completes_without_reasking(): + # The happy path under pinning: a stored accept answer whose question is + # unchanged restores on a later round and the flow completes without the + # question being asked a second time. + mcp = MCPServer(name="PinHappyPath", request_state_security=RequestStateSecurity.ephemeral()) + + async def who(ctx: Context) -> Elicit[Login]: + return Elicit("Who?", Login) + + async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"Go as {login.username}?", Confirm) + + @mcp.tool() + async def act( + login: Annotated[Login, Resolve(who)], + confirm: Annotated[Confirm, Resolve(check)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert set(first.input_requests) == {_wire_key(who)} + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + # Only the dependent question; the stored answer holds, "Who?" is not re-asked. + assert set(second.input_requests) == {_wire_key(check)} + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_restored_entry_is_repersisted_with_its_question_digest_intact(): + # An entry restored into a round that still pends is carried into that round's + # `request_state` unchanged - including its question digest - or the answer + # would be dropped and re-asked on the round after. + mcp = MCPServer(name="RepersistPin", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def who(ctx: Context) -> Elicit[Login]: + return Elicit("Who?", Login) + + async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"Go as {login.username}?", Confirm) + + async def plan(confirm: Annotated[Confirm, Resolve(check)], ctx: Context) -> Elicit[Restock]: + return Elicit("Restock too?", Restock) + + # The test stops while a question pends, so the body never runs: a bare `...` + # is a constant statement the compiler eliminates - nothing for coverage to miss. + @mcp.tool() + async def act(restock: Annotated[Restock, Resolve(plan)]) -> str: ... + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(third, InputRequiredResult) + + round_two = _outcomes_on_the_wire(second.request_state) + round_three = _outcomes_on_the_wire(third.request_state) + # Accept entries are pinned to the exact rendered question they answered. + assert round_two[_wire_key(who)]["q"] == _question_digest(Elicit("Who?", Login)) + assert round_three[_wire_key(check)]["q"] == _question_digest(Elicit("Go as octocat?", Confirm)) + # The restored entry rides into round 3's state exactly as round 2 stored it. + assert round_three[_wire_key(who)] == round_two[_wire_key(who)] + + +@pytest.mark.anyio +async def test_decline_and_cancel_entries_carry_the_question_digest(): + mcp = MCPServer(name="PinAllActions", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + async def ask_restock(ctx: Context) -> Elicit[Restock]: + return Elicit("Restock?", Restock) + + # The test stops while a question pends, so the body never runs: a bare `...` + # is a constant statement the compiler eliminates - nothing for coverage to miss. + @mcp.tool() + async def act( + name: Annotated[ElicitationResult[Login], Resolve(ask_name)], + confirm: Annotated[ElicitationResult[Confirm], Resolve(ask_confirm)], + restock: Annotated[Restock, Resolve(ask_restock)], + ) -> str: ... + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + # Decline one question and cancel another; the third stays unanswered so the + # call pends and the recorded outcomes are observable on the wire. + second = await client.session.call_tool( + "act", + {}, + input_responses={ + _wire_key(ask_name): ElicitResult(action="decline"), + _wire_key(ask_confirm): ElicitResult(action="cancel"), + }, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + outcomes = _outcomes_on_the_wire(second.request_state) + assert outcomes[_wire_key(ask_name)]["action"] == "decline" + assert outcomes[_wire_key(ask_name)]["q"] == _question_digest(Elicit("Name?", Login)) + assert outcomes[_wire_key(ask_confirm)]["action"] == "cancel" + assert outcomes[_wire_key(ask_confirm)]["q"] == _question_digest(Elicit("Confirm?", Confirm)) + + +@pytest.mark.anyio +async def test_state_entry_without_a_question_digest_is_dropped_and_reasked(): + # v2 semantics: an entry whose `q` is None (absent digest) cannot prove which + # rendered question it answered, so it reads as no recorded progress - the live + # question is asked again rather than honoring an unpinned answer - and a proper + # answer then completes the call. + mcp = MCPServer(name="UnpinnedEntry", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("whoami", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (key,) = first.input_requests + + # Schema-valid accept data under the live key, but no "q" pin. + entry = {"action": "accept", "data": {"username": "spooky"}} + crafted = json.dumps({"v": 2, "outcomes": {key: entry}}) + second = await client.session.call_tool( + "whoami", + {}, + request_state=_sealed_state(crafted, tool="whoami", args={}, audience="UnpinnedEntry"), + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) # re-asked, not honored and not an error + assert second.input_requests is not None + assert set(second.input_requests) == {key} + assert _outcomes_on_the_wire(second.request_state) == {} # the unpinned entry is dropped + + final = await client.session.call_tool( + "whoami", + {}, + input_responses={key: ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat" + + +@pytest.mark.anyio +async def test_reworded_question_drops_the_stored_answer_and_reasks(): + # A stored accept answer is honored only while the question is byte-identical: + # rewording it between rounds (a redeploy) drops the entry and re-asks - a soft + # self-heal, never an error - while other entries in the same state survive. + mcp = MCPServer(name="RewordAccept", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + wording = {"deploy": "Deploy to prod?"} + + async def ask_deploy(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["deploy"], Confirm) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + @mcp.tool() + async def act( + deploy: Annotated[Confirm, Resolve(ask_deploy)], + name: Annotated[Login, Resolve(ask_name)], + ) -> str: + return f"{deploy.ok}:{name.username}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert _outcomes_on_the_wire(second.request_state)[_wire_key(ask_deploy)]["q"] == _question_digest( + Elicit("Deploy to prod?", Confirm) + ) + + # The server rewords the question between rounds (a redeploy). + wording["deploy"] = "Deploy to staging?" + + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + # The stale answer is dropped and the reworded question is asked - not an error. + assert isinstance(third, InputRequiredResult) + assert third.input_requests is not None + assert set(third.input_requests) == {_wire_key(ask_deploy)} + question = third.input_requests[_wire_key(ask_deploy)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Deploy to staging?" + # The sibling answer recorded in the same state survives the drop. + outcomes = _outcomes_on_the_wire(third.request_state) + assert _wire_key(ask_deploy) not in outcomes + assert outcomes[_wire_key(ask_name)]["action"] == "accept" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=third.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "True:octocat" + + +@pytest.mark.anyio +async def test_decline_of_a_reworded_question_does_not_suppress_the_new_question(): + # Decline entries are pinned too: a decline recorded for question A must not + # suppress re-asking once the question is reworded into question B. + mcp = MCPServer(name="RewordDecline", request_state_security=RequestStateSecurity.ephemeral()) + wording = {"q": "Use defaults?"} + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["q"], Confirm) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + @mcp.tool() + async def act( + choice: Annotated[ElicitationResult[Confirm], Resolve(ask)], + name: Annotated[Login, Resolve(ask_name)], + ) -> str: + kind = "accepted" if isinstance(choice, AcceptedElicitation) else "declined" + return f"{kind}:{name.username}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="decline")}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + wording["q"] = "Use the new defaults?" + + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + # The stale decline is dropped and the reworded question is asked again. + assert isinstance(third, InputRequiredResult) + assert third.input_requests is not None + assert set(third.input_requests) == {_wire_key(ask)} + question = third.input_requests[_wire_key(ask)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Use the new defaults?" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=third.request_state, + allow_input_required=True, + ) + # Accepting the new question proves the old decline did not stick. + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "accepted:octocat" From b61d141eda8487e2b2dd0763877bb97ff7bcaca6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:58:04 +0000 Subject: [PATCH 3/8] Scope the construction-time requirement to resolver tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup requirement now applies exactly where the SDK authors the requestState content itself: tools with Resolve(...) parameters, whose state carries elicited answers the server later trusts. Hand-built requestState — a tool, prompt, or resource template returning InputRequiredResult directly — is user-authored, so nothing is required there: an unconfigured server passes it through verbatim, and the docs make protection a clear recommendation instead. Configuring request_state_security= still seals hand-built state on the three carrier methods with no code changes. Consequences: the boundary middleware is installed only when a policy is supplied (no more unconfigured rejection paths), the unprotected() opt-out is deleted (not configuring is the unprotected posture), and the boundary scopes strictly to the carrier methods — requestState- shaped members on custom methods belong to their own protocols and are neither sealed nor policed. All cryptographic hardening, the claims envelope, and resolver question-pinning are unchanged. --- docs/advanced/multi-round-trip.md | 25 +- docs/migration.md | 12 +- docs_src/mrtr/tutorial004.py | 4 +- examples/stories/mrtr/README.md | 3 + src/mcp/server/mcpserver/server.py | 152 ++++-------- src/mcp/server/request_state.py | 135 ++++------- tests/client/test_client.py | 12 +- tests/server/mcpserver/test_server.py | 43 ++-- tests/server/test_request_state.py | 22 +- tests/server/test_request_state_boundary.py | 175 ++++++-------- tests/server/test_request_state_gate.py | 242 +++++--------------- 11 files changed, 261 insertions(+), 564 deletions(-) diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 5a80b19a8..ff8fbda54 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -35,12 +35,12 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT `tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context: -```python title="server.py" hl_lines="6 21 23 25" +```python title="server.py" hl_lines="21 23 25" --8<-- "docs_src/mrtr/tutorial004.py" ``` * The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. -* The `request_state_security=` argument is not optional: declaring an `InputRequiredResult` return means this server can mint a `requestState`, and `MCPServer` refuses to construct until you choose how to protect it. `ephemeral()` is the right answer for a single-process server like this one; **[Protecting `requestState`](#protecting-requeststate)** below covers what it does and the other choices. +* Nothing extra is required to register this form — only `Resolve(...)` tools force a `request_state_security=` choice at construction. But if your function sets a `request_state`, what the client echoes back is client-supplied input; **[Protecting `requestState`](#protecting-requeststate)** below covers why you should configure protection anyway, and what you get when you do. * An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. @@ -89,9 +89,9 @@ Drop to the underlying session, where `allow_input_required=True` hands you the Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs — writing it down across processes is exactly what the previous section blessed — so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. -This SDK is deliberately stricter than that conditional requirement: `MCPServer` refuses to construct at all while any registration can mint a `requestState` — a `Resolve(...)` parameter, or a tool, prompt, or resource-template function declaring an `InputRequiredResult` return — until you pass `request_state_security=`. The alternative is a server that runs fine in development and ships unprotected state the first time it matters. +The SDK requires a protection choice exactly where it authors the state itself: registering a `Resolve(...)` tool refuses to construct until you pass `request_state_security=`, because resolver state carries elicited answers the server will later trust. For state **you** build — returning `InputRequiredResult` from a tool, prompt, or resource template — nothing is required. But the echoed value is attacker-controlled input all the same, so you should configure protection there too: with `request_state_security=` set, your hand-built state is sealed and verified by the same machinery with zero code changes — write plaintext, read plaintext. Without it, your state crosses the wire exactly as written, and the spec's integrity requirement is yours to satisfy — running unconfigured is a risk you accept, not a default the SDK chose for you. -There are three choices: +There are two configurations: ```python from mcp.server.mcpserver import MCPServer, RequestStateSecurity @@ -101,18 +101,15 @@ mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]) # Single process (stdio, one HTTP worker): a key generated at startup. mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral()) - -# No protection. Read the caveats before reaching for this. -mcp = MCPServer("wizard", request_state_security=RequestStateSecurity.unprotected()) ``` * `keys=[...]` is the built-in encrypting codec under your secret(s). Required whenever a retry can reach a **different instance** — multi-worker or load-balanced HTTP — because every instance must be able to verify what any sibling minted. -* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over — right for a single process, wrong for a fleet. The tutorial servers in these docs all use it for that reason. -* `.unprotected()` sends state exactly as handlers wrote it and accepts whatever comes back. The spec permits this only when tampering can cause nothing worse than a failed request. `Resolve(...)` tools refuse this mode at registration: their state carries elicited answers, which are business inputs. +* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over — right for a single process, wrong for a fleet. The resolver tutorials in these docs use it for that reason. +* For your own crypto — a KMS, an existing token service — pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. ### What the seal carries -With either of the first two choices, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: +With either built-in configuration, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: * **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. * **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK — a fronting proxy — or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. @@ -153,11 +150,13 @@ Every inbound failure — tampered, expired, replayed against a different reques {"code": -32602, "message": "Invalid or expired requestState"} ``` -One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. A server that never mints state at all — no MRTR registrations, no `request_state_security=` — rejects any inbound `requestState` the same way. +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Verification is a configured server's behavior: with `request_state_security=` set, every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked — including one arriving for a handler that never mints state. Without it, nothing is checked: inbound state reaches your handler exactly as the client sent it. ### Hand-built state -A `request_state` you set yourself — returning `InputRequiredResult` from a tool, prompt, or resource-template function — is sealed and verified by the same machinery: write plaintext, read plaintext. The one thing the SDK cannot pin for you is question identity, because it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. +A `request_state` you set yourself — returning `InputRequiredResult` from a tool, prompt, or resource-template function — never requires `request_state_security=`. Configure it anyway and your hand-built state is sealed and verified by the same machinery, with zero code changes: write plaintext, read plaintext, and every binding above applies. Don't, and the state crosses the wire exactly as written — whatever comes back is the client's word, and the spec's integrity requirement is yours to satisfy before you act on it. + +The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. The low-level `Server` is the no-batteries tier: nothing is required at construction and nothing is sealed until you append the boundary yourself — one line, shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. @@ -185,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. -* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will mint one, and the seal binds every token to a time window, the originating request, and — when the request carries auth the SDK validated, or `bind_principal=` supplies your own identity signal — the authenticated client (**[Protecting `requestState`](#protecting-requeststate)**). +* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and — when the request carries auth the SDK validated, or `bind_principal=` supplies your own identity signal — the authenticated client (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 553dbf83d..4d2cbf6e7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -423,9 +423,9 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. -### Servers that mint `requestState` must configure `request_state_security=` +### Tools with `Resolve(...)` parameters require `request_state_security=` -`requestState` round-trips through the client, so what comes back is client-supplied input. `MCPServer` now requires a protection choice at construction from any server that can mint one: registering a tool that uses `Resolve(...)` parameters, or a tool, prompt, or resource-template function that declares an `InputRequiredResult` return, raises `ValueError` until you pass `request_state_security=`. The one-line fix for a single-process server: +`requestState` round-trips through the client, so what comes back is client-supplied input. `MCPServer` now requires a protection choice where the SDK authors that state itself: registering a tool that uses `Resolve(...)` parameters raises `ValueError` until you pass `request_state_security=`, because resolver state carries elicited answers the server later trusts. The one-line fix for a single-process server: ```python from mcp.server.mcpserver import MCPServer, RequestStateSecurity @@ -433,13 +433,9 @@ from mcp.server.mcpserver import MCPServer, RequestStateSecurity mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral()) ``` -Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted, and `RequestStateSecurity.unprotected()` is the explicit opt-out for manual flows where tampering can cause nothing worse than a failed request (refused at registration for `Resolve(...)` tools). The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). +Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). -Three behavior changes ride along: - -* On a protected server, `ctx.request_state` returns the verified plaintext your handler originally wrote, not the wire token — sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. -* A handler that returns an `InputRequiredResult` carrying `requestState` without having declared that return type — no annotation, or annotations the registration gate cannot resolve — on a server with no `request_state_security=` now answers `-32603` *"Internal error"* instead of shipping the state unprotected. The remediation goes to the server log: declare the return type, or configure `request_state_security=`. -* A server that never minted any state (no MRTR-capable registrations, no `request_state_security=`) now rejects any inbound `requestState` with `-32602` *"Invalid or expired requestState"* — the same frozen error every protected server answers when a token fails verification. +On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote — sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too. ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py index 3a8679b05..05b945935 100644 --- a/docs_src/mrtr/tutorial004.py +++ b/docs_src/mrtr/tutorial004.py @@ -1,9 +1,9 @@ from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult -from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity +from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.prompts.base import UserMessage -mcp = MCPServer("Briefing", request_state_security=RequestStateSecurity.ephemeral()) +mcp = MCPServer("Briefing") ASK_AUDIENCE = ElicitRequest( params=ElicitRequestFormParams( diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 34290ab65..86ba02373 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -27,6 +27,9 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel - `server.py` `build_server` — the whole security opt-in is one constructor argument: `request_state_security=RequestStateSecurity.ephemeral()`. + Opting in is this server's choice — only tools with `Resolve(...)` + parameters are required to configure protection; a hand-built flow like + `deploy` would otherwise send its state across the wire as plaintext. `ephemeral()` generates a key at process start, which is right for a single-process server like this one; a fleet (multi-worker or load-balanced) shares keys with `RequestStateSecurity(keys=[...])` so any instance can diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index e49d18088..36e22f7bf 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -import enum import inspect from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager @@ -74,7 +73,7 @@ from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError from mcp.server.mcpserver.prompts import Prompt, PromptManager -from mcp.server.mcpserver.resolve import find_resolved_parameters, returns_input_required +from mcp.server.mcpserver.resolve import find_resolved_parameters from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, FunctionResource, @@ -148,56 +147,14 @@ async def wrap(_: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]: return wrap -class _MrtrCapability(enum.Enum): - """Why a registration can mint `requestState` (a multi-round-trip carrier).""" - - RESOLVER = "uses Resolve(...) parameters" - DECLARED_MANUAL = "declares an InputRequiredResult return" - - -def _mrtr_capability(fn: Callable[..., Any]) -> _MrtrCapability | None: - """Why this tool function can mint `requestState`, or None if it can't. - - A function declaring both capabilities is an invalid registration that - `Tool.from_function` rejects with its own error (one call has one - input_required channel), so the gate stands aside for that combination; - for every valid registration at most one capability applies. - """ - resolved = find_resolved_parameters(fn) - declared = returns_input_required(fn) - if resolved and declared: - return None - if resolved: - return _MrtrCapability.RESOLVER - if declared: - return _MrtrCapability.DECLARED_MANUAL - return None - - -def _format_missing_security(owner: str, capability: _MrtrCapability, *, opted_out: bool) -> str: - """The teaching error for an MRTR-capable registration with no usable protection. - - `opted_out` selects the closing block: an unconfigured server is shown the - `unprotected()` escape hatch; a server that already chose `unprotected()` is - told why a resolver tool refuses it. - """ - if opted_out: - closing = ( - " Resolve(...) tools cannot opt out: their requestState carries elicited\n" - " answers, which are business inputs. Use keys=[...] or .ephemeral()." - ) - else: - closing = ( - " MCPServer(..., request_state_security=RequestStateSecurity.unprotected())\n" - " No protection. Only valid when tampering can cause nothing worse than a\n" - " failed request - not available for Resolve(...) tools, whose state\n" - " carries elicited answers." - ) +def _format_missing_security(owner: str) -> str: + """The teaching error for a resolver tool registered without request-state security.""" return ( - f"{owner} {capability.value}, so this server mints a\n" - "requestState that round-trips through the client. The MCP spec requires that state\n" - "to be integrity-protected, and rejected when verification fails, whenever it can\n" - "influence authorization, resource access, or business logic. Configure protection:\n" + f"{owner} uses Resolve(...) parameters, so this server mints a\n" + "requestState carrying elicited answers that round-trips through the client. The\n" + "MCP spec requires that state to be integrity-protected, and rejected when\n" + "verification fails, whenever it can influence authorization, resource access,\n" + "or business logic. Configure protection:\n" "\n" " MCPServer(..., request_state_security=RequestStateSecurity(keys=[key]))\n" " One or more shared secret keys (>= 32 bytes each). Required when a retry\n" @@ -210,7 +167,8 @@ def _format_missing_security(owner: str, capability: _MrtrCapability, *, opted_o " (stdio, one HTTP worker): state minted before a restart, or by another\n" " instance, is rejected and the client must restart the flow.\n" "\n" - f"{closing}\n" + "For your own crypto (a KMS, an existing token service), pass\n" + "RequestStateSecurity(codec=...).\n" "\n" "Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr" ) @@ -283,22 +241,23 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) - # The boundary owns `requestState` at the wire in both directions. It is - # installed unconditionally so an unconfigured server fails safe (inbound - # state is rejected, an outbound emission is an internal error - never - # silent plaintext), and it is appended here - after the lowlevel server - # is built and before `_install_extension_interceptor` - so it sits - # inside OpenTelemetry (spans record the sealed wire truth) and outside - # extension interceptors (extensions see plaintext). The server name is - # the default audience, so services sharing a key reject each other's + # The boundary owns `requestState` at the wire in both directions when + # security is configured; without it, `requestState` passes through + # untouched (the explicitly unprotected posture). It is appended here - + # after the lowlevel server is built and before + # `_install_extension_interceptor` - so it sits inside OpenTelemetry + # (spans record the sealed wire truth) and outside extension + # interceptors (extensions see plaintext). The server name is the + # default audience, so services sharing a key reject each other's # state unless the policy names its own audience. - self._lowlevel_server.middleware.append( - RequestStateBoundary(request_state_security, default_audience=self.name) - ) + if request_state_security is not None: + self._lowlevel_server.middleware.append( + RequestStateBoundary(request_state_security, default_audience=self.name) + ) # Constructor-supplied Tool objects bypass `add_tool` (ToolManager # inserts them directly), so gate them here, before any client connects. for tool in self._tool_manager.list_tools(): - self._check_mrtr_protection(tool, owner=f"Tool {tool.name!r}") + self._check_resolver_protection(tool, owner=f"Tool {tool.name!r}") # Validate auth configuration if self.settings.auth is not None: if auth_server_provider and token_verifier: # pragma: no cover @@ -615,36 +574,29 @@ async def read_resource( # If an exception happens when reading the resource, we should not leak the exception to the client. raise ResourceError(f"Error reading resource {uri}") from exc - def _check_mrtr_protection(self, subject: Tool | Callable[..., Any], *, owner: str) -> None: - """Refuse an MRTR-capable registration when the server has no request-state posture. + def _check_resolver_protection(self, subject: Tool | Callable[..., Any], *, owner: str) -> None: + """Refuse a resolver-tool registration when the server has no request-state security. - The single gate for every registration funnel; it derives the MRTR - capability itself. A `Tool` is judged by its stored authority - - `resolved_params` decides RESOLVER directly, with no combo-deferral, - because a hand-built Tool has no `from_function` validation to defer - to. A bare callable (the `add_tool`/`add_prompt`/`resource` funnels) is - judged by signature inspection, where `Tool.from_function`'s own combo - rejection takes precedence. + Resolver state carries elicited answers — business inputs the SDK + itself authors — so the spec's integrity requirement is not optional + for it. Manual `InputRequiredResult` flows (tools, prompts, resource + templates) are not gated: their state is user-authored, and an + unconfigured server passes it through as plaintext (protection is + recommended, not required). Runs before the registration reaches its manager, so a rejected - registration leaves no trace and the server stays usable. Raises the - teaching ValueError when the capability is set and the server has no - `request_state_security=` (or only `unprotected()`, which resolver - tools refuse - their state carries elicited answers). + registration leaves no trace and the server stays usable. A `Tool` is + judged by its stored `resolved_params`; a bare callable (the + `add_tool` funnel) by signature scan, before `Tool.from_function` + runs — for a function that also declares an `InputRequiredResult` + return (a combination `from_function` rejects), this gate fires + first; configuring security then surfaces the signature error. """ - if isinstance(subject, Tool): - if subject.resolved_params: - capability = _MrtrCapability.RESOLVER - else: - capability = _MrtrCapability.DECLARED_MANUAL if returns_input_required(subject.fn) else None - else: - capability = _mrtr_capability(subject) - if capability is None: + if self._request_state_security is not None: return - security = self._request_state_security - if security is not None and not (security.is_unprotected and capability is _MrtrCapability.RESOLVER): - return - raise ValueError(_format_missing_security(owner, capability, opted_out=security is not None)) + resolved = subject.resolved_params if isinstance(subject, Tool) else find_resolved_parameters(subject) + if resolved: + raise ValueError(_format_missing_security(owner)) def add_tool( self, @@ -676,12 +628,10 @@ def add_tool( - If False, unconditionally creates an unstructured tool Raises: - ValueError: If the tool can mint `requestState` (it uses - `Resolve(...)` parameters or declares an `InputRequiredResult` - return) and the server was constructed without a usable - `request_state_security=`. + ValueError: If the tool uses `Resolve(...)` parameters and the + server was constructed without `request_state_security=`. """ - self._check_mrtr_protection(fn, owner=f"Tool {name or fn.__name__!r}") + self._check_resolver_protection(fn, owner=f"Tool {name or fn.__name__!r}") self._tool_manager.add_tool( fn, name=name, @@ -880,11 +830,9 @@ async def get_weather(city: str) -> str: Raises: InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template. ValueError: If URI template parameters don't match the - function's parameters, if a parameter bound to a + function's parameters, or if a parameter bound to a ``{?...}``/``{&...}`` query variable has no default - (the client may omit it), or if a template function declares - an `InputRequiredResult` return on a server constructed - without `request_state_security=`. + (the client may omit it). TypeError: If the decorator is applied without being called (``@resource`` instead of ``@resource("uri")``). """ @@ -933,8 +881,6 @@ def decorator(fn: _CallableT) -> _CallableT: f"default." ) - self._check_mrtr_protection(fn, owner=f"Resource template {uri!r}") - # Register as template self._resource_manager.add_template( fn=fn, @@ -985,13 +931,7 @@ def add_prompt(self, prompt: Prompt) -> None: Args: prompt: A Prompt instance to add - - Raises: - ValueError: If the prompt function declares an - `InputRequiredResult` return and the server was constructed - without `request_state_security=`. """ - self._check_mrtr_protection(prompt.fn, owner=f"Prompt {prompt.name!r}") self._prompt_manager.add_prompt(prompt) def prompt( diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py index e1e0aad7f..aa6e26c58 100644 --- a/src/mcp/server/request_state.py +++ b/src/mcp/server/request_state.py @@ -8,8 +8,8 @@ This module is the composable tier: `RequestStateBoundary` is a server middleware that seals every outgoing `requestState` and unseals (verifies) every inbound echo, so handlers and resolvers only ever see the plaintext state they minted. -`MCPServer` installs it automatically from its `request_state_security=` -parameter; lowlevel `Server` users append it to `Server.middleware` themselves. +`MCPServer` installs it when its `request_state_security=` parameter is +supplied; lowlevel `Server` users append it to `Server.middleware` themselves. """ from __future__ import annotations @@ -120,7 +120,6 @@ class RequestStateSecurity: RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM, shared key(s) RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto RequestStateSecurity.ephemeral() # process-local key; single process only - RequestStateSecurity.unprotected() # explicit opt-out (read its docstring) `keys` is the rotation ring: `keys[0]` seals new state; every key may unseal. Zero-downtime rotation is three phases (each fully rolled out @@ -144,7 +143,7 @@ class RequestStateSecurity: `default_audience`). """ - codec: RequestStateCodec | None + codec: RequestStateCodec ttl: float bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None audience: str | None @@ -157,20 +156,16 @@ def __init__( ttl: float = 600.0, bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None = authenticated_principal, audience: str | None = None, - _unprotected: bool = False, ) -> None: - if _unprotected: - # `unprotected()`'s spelling: no codec, no binding, no audience; `ttl` is never read. - self.codec = None - self.ttl = ttl - self.bind_principal = None - self.audience = None - return if (keys is None) == (codec is None): raise ValueError("RequestStateSecurity takes exactly one of keys= or codec=") if not (math.isfinite(ttl) and ttl > 0): raise ValueError(f"request-state ttl must be a positive finite number, got {ttl!r}") - self.codec = AESGCMRequestStateCodec(keys) if keys is not None else codec + if keys is not None: + self.codec = AESGCMRequestStateCodec(keys) + else: + assert codec is not None # the exactly-one-of check above + self.codec = codec self.ttl = ttl self.bind_principal = bind_principal self.audience = audience @@ -190,25 +185,6 @@ def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> Reques """ return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) - @classmethod - def unprotected(cls) -> RequestStateSecurity: - """No protection: `requestState` crosses the wire exactly as handlers wrote it. - - The spec permits this ONLY "when tampering can cause nothing worse than - request failure" (basic/patterns/mrtr). A client can then read, forge, - and replay your state at will — never put data that influences - authorization, resource access, or business logic in it. A server - configured this way fails the `input-required-result-tampered-state` - conformance scenario by design. Resolver-driven tools - (`Resolve(...)` parameters) refuse this mode at registration: their - state carries elicited answers, which are business inputs. - """ - return cls(_unprotected=True) - - @property - def is_unprotected(self) -> bool: - return self.codec is None - _KDF_INFO = b"mcp/request-state/v1/aes-256-gcm" _KID_INFO = b"mcp/request-state/v1/kid:" @@ -371,22 +347,24 @@ def _principal_matches(claim: str, principal: str) -> bool: class RequestStateBoundary: """Server middleware sealing/unsealing `requestState` at the wire boundary. - Inbound: a request presenting `requestState` (any non-null value, on any - method) is handled before any extension interceptor or handler runs. On the - multi-round-trip carriers (tools/call, prompts/get, resources/read) the - value is verified (codec unseal + claims check: version, mint-time skew, - expiry, method, target, argument digest, audience, principal) and replaced - with the plaintext the server originally minted. Every other method has no - legal carrier for the field, so the request is rejected outright. - Verification failure answers a wire-level -32602 with the frozen message - "Invalid or expired requestState"; the underlying reason goes to the server - log only. - - Outbound: an `input_required` result carrying `requestState` on a - multi-round-trip carrier has it sealed inside a fresh claims envelope; on - any other method an emission is a server bug answered as an internal error, - never silent plaintext. Handlers and resolvers write plaintext and never - call the codec themselves. + The boundary acts only on the multi-round-trip carriers (tools/call, + prompts/get, resources/read) — the only methods whose results may carry + `requestState`. Every other method passes through untouched: a + "requestState" member appearing in some other method's params is outside + the multi-round-trip protocol, is never verified, and must never be + trusted by whatever handles it. + + Inbound: a carrier request presenting `requestState` (any non-null value) + is handled before any extension interceptor or handler runs: the value is + verified (codec unseal + claims check: version, mint-time skew, expiry, + method, target, argument digest, audience, principal) and replaced with + the plaintext the server originally minted. Verification failure answers a + wire-level -32602 with the frozen message "Invalid or expired + requestState"; the underlying reason goes to the server log only. + + Outbound: an `input_required` result carrying `requestState` has it sealed + inside a fresh claims envelope. Handlers and resolvers write plaintext and + never call the codec themselves. `default_audience` seeds the envelope's audience claim when the policy does not set its own `audience`. `MCPServer` passes its server name, so two @@ -399,34 +377,28 @@ class RequestStateBoundary: with `dataclasses.replace(ctx, params=...)` — the rewrite contract `ServerMiddleware` sanctions. - `MCPServer` installs this automatically from `request_state_security=`. - Lowlevel `Server` users append one to `server.middleware` — they get the - identical claims enforcement; nothing is private to MCPServer. - - With `security=None` (an `MCPServer` that has no MRTR registrations and no - configuration) the boundary fails safe at runtime: inbound `requestState` - is rejected — this server never minted one — and an outbound emission is a - server bug answered as an internal error, never silent plaintext. Declared - MRTR surfaces never reach that branch — registration already failed at - construction (see the startup gate) — while statically-undetectable cases - (unannotated returns, TYPE_CHECKING-only annotations, wrapped functions) - land on the loud runtime error instead. + `MCPServer` installs this only when `request_state_security=` is supplied; + without it, `requestState` passes through untouched — the explicitly + unprotected posture, which the spec permits only when tampering can cause + nothing worse than a failed request. Protection is required only for + resolver tools (`Resolve(...)` parameters — state the SDK itself authors) + and recommended for everything else. Lowlevel `Server` users append one to + `server.middleware` — they get the identical claims enforcement; nothing + is private to MCPServer. """ - def __init__(self, security: RequestStateSecurity | None, *, default_audience: str | None = None) -> None: + def __init__(self, security: RequestStateSecurity, *, default_audience: str | None = None) -> None: self._security = security - self._audience = ( - security.audience if security is not None and security.audience is not None else default_audience - ) + self._audience = security.audience if security.audience is not None else default_audience async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method not in _MRTR_METHODS: + return await call_next(ctx) binding: _RoundBinding | None = None if ctx.params is not None and ctx.params.get("requestState") is not None: # An explicit JSON null is the field's absence (a fresh flow): only # presented state is verified, and stripping the field is already # in any client's power. - if ctx.method not in _MRTR_METHODS: - _reject(ctx.method, "requestState on a non-MRTR method") plaintext, binding = self._unseal(ctx) ctx = replace(ctx, params={**ctx.params, "requestState": plaintext}) result = await call_next(ctx) @@ -434,17 +406,12 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex # -- inbound ------------------------------------------------------------ - def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding | None]: + def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding]: assert ctx.params is not None wire = ctx.params["requestState"] if not isinstance(wire, str): _reject(ctx.method, "non-string requestState") security = self._security - if security is None: - _reject(ctx.method, "requestState received but no request_state_security is configured") - if security.is_unprotected: - return wire, None - assert security.codec is not None try: payload = security.codec.unseal(wire) except InvalidRequestState as exc: @@ -498,14 +465,6 @@ def _seal_result( state = result.get("requestState") if isinstance(result, Mapping) else result.request_state if state is None: return result - if ctx.method not in _MRTR_METHODS: - logger.error( - "handler for %s returned an input_required result carrying requestState, but the spec " - "restricts InputRequiredResult to tools/call, prompts/get, and resources/read; extension " - "and custom methods must not mint requestState. Refusing to send it.", - ctx.method, - ) - raise MCPError(code=INTERNAL_ERROR, message="Internal error") if isinstance(result, Mapping): if not isinstance(state, str): # Only a short-circuiting middleware can put a non-string here @@ -517,22 +476,6 @@ def _seal_result( def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str: security = self._security - if security is None: - # Reachable only by an *undeclared* dynamic InputRequiredResult - # return (declared surfaces already failed at construction). Never - # emit unprotected state silently; tell the operator exactly what - # to do, in the log, and fail the request. - logger.error( - "handler for %s returned an InputRequiredResult with requestState, but no " - "request_state_security is configured on this server; refusing to send unprotected " - "state. Pass request_state_security=RequestStateSecurity(...) to MCPServer " - "(or .ephemeral() for single-process, or .unprotected() to accept the risk).", - ctx.method, - ) - raise MCPError(code=INTERNAL_ERROR, message="Internal error") - if security.is_unprotected: - return state - assert security.codec is not None if binding is None: target, args_digest = _request_identity(ctx.method, ctx.params) try: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index dbae4c0e0..820478f3f 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -42,7 +42,7 @@ from mcp.client.session import ClientRequestContext from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity +from mcp.server.mcpserver import Context, MCPServer from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage from tests.interaction._connect import BASE_URL, mounted_app @@ -625,7 +625,7 @@ async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_res """When the server returns `InputRequiredResult` carrying an elicitation, `Client.call_tool` routes it to `elicitation_callback` and retries automatically — the caller sees only the terminal `CallToolResult`.""" - server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) + server = MCPServer("test") @server.tool() async def greet(ctx: Context) -> str | types.InputRequiredResult: @@ -662,7 +662,7 @@ async def elicitation_callback( async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `CreateMessageRequest` is routed to `sampling_callback` and the call retried with the model's reply.""" - server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) + server = MCPServer("test") @server.tool() async def ask(ctx: Context) -> str | types.InputRequiredResult: @@ -707,7 +707,7 @@ async def sampling_callback( async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None: """`InputRequiredResult` with an embedded `ListRootsRequest` is routed to `list_roots_callback` and the call retried with the returned roots.""" - server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) + server = MCPServer("test") @server.tool() async def count_roots(ctx: Context) -> str | types.InputRequiredResult: @@ -742,7 +742,7 @@ async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_thr """A three-round flow where each `InputRequiredResult.request_state` encodes the round number: the driver echoes it back byte-exact, the server advances per round, and the elicitation callback runs once per round.""" - server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) + server = MCPServer("test") @server.tool() async def multi(ctx: Context) -> str | types.InputRequiredResult: @@ -777,7 +777,7 @@ async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered( """SDK-defined: with no `elicitation_callback`, the default returns `ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError` rather than retrying.""" - server = MCPServer("test", request_state_security=RequestStateSecurity.ephemeral()) + server = MCPServer("test") @server.tool() async def needs_input(ctx: Context) -> str | types.InputRequiredResult: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 0e72cb8c3..ecae00fdb 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -46,7 +46,7 @@ from mcp.client import Client from mcp.server.context import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity, ResourceSecurity +from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource @@ -1867,10 +1867,9 @@ def get_user(user_id: str) -> str: async def test_tool_returning_input_required_result_reaches_client_unchanged(): - # unprotected(): this test pins plaintext passthrough - the wire carries the - # handler's requestState exactly as written, the opt-out posture a - # declared-manual surface may choose. - mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) + # Unconfigured server: the wire carries the handler's requestState exactly as + # written, the plaintext posture a declared-manual surface gets by default. + mcp = MCPServer() @mcp.tool() async def ask(ctx: Context) -> str | InputRequiredResult: @@ -1887,7 +1886,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: async def test_tool_reads_input_responses_and_request_state_from_context_on_retry(): - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.tool() async def greet(ctx: Context) -> str | InputRequiredResult: @@ -1947,9 +1946,9 @@ async def test_prompt_returning_input_required_result_reaches_client_unchanged() """A prompt function may return an InputRequiredResult and the pipeline passes it through to the client (spec-mandated: SEP-2322 allows it on prompts/get). - unprotected(): the assertion is on the verbatim wire requestState, the - opt-out posture a declared-manual surface may choose.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) + Unconfigured server: the assertion is on the verbatim wire requestState, the + plaintext posture a declared-manual surface gets by default.""" + mcp = MCPServer() @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -1968,7 +1967,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_prompt_reads_input_responses_and_request_state_from_context_on_retry(): """The prompts/get retry carries input_responses and request_state to the prompt function via the Context, completing the SEP-2322 multi-round-trip flow.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -2000,7 +1999,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so the runner rejects the frame with -32603 — the same posture the tools path has.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.prompt() async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: @@ -2016,7 +2015,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate for resources/read: a pre-2026 session has no input_required vocabulary, so the runner rejects the frame with -32603.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2033,9 +2032,9 @@ async def test_resource_template_returning_input_required_result_reaches_client_ """A resource template function may return an InputRequiredResult and the pipeline passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read). - unprotected(): the assertion is on the verbatim wire requestState, the - opt-out posture a declared-manual surface may choose.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) + Unconfigured server: the assertion is on the verbatim wire requestState, the + plaintext posture a declared-manual surface gets by default.""" + mcp = MCPServer() @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2054,7 +2053,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_resource_template_reads_input_responses_from_context_on_retry(): """The resources/read retry carries input_responses to the template function via the Context, completing the SEP-2322 multi-round-trip flow.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2085,7 +2084,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_context_read_resource_raises_on_input_required_result(): """ctx.read_resource is a content reader: an InputRequiredResult from the template raises with a pointer at the forwarding path instead of widening every caller.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() @mcp.resource("ask://{topic}") async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: @@ -2103,7 +2102,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_mcpserver_read_resource_returns_input_required_result_for_handler_forwarding(): """MCPServer.read_resource hands the template's InputRequiredResult to a direct caller unchanged — the composition path for a handler that forwards it as its own result.""" - mcp = MCPServer(request_state_security=RequestStateSecurity.ephemeral()) + mcp = MCPServer() sentinel = InputRequiredResult(input_requests={"who": _ask_who()}) @mcp.resource("ask://{topic}") @@ -2120,10 +2119,10 @@ async def test_context_read_resource_keeps_outer_input_responses_from_the_nested template must not see the outer request's input_responses/request_state — a colliding key would otherwise consume an answer meant for the outer handler's own question. - unprotected(): the probe below is client-built plaintext state that must reach - the outer request's context as-sent - the subject is nested-context isolation, - not the wire seal (no surface here mints state at all).""" - mcp = MCPServer(request_state_security=RequestStateSecurity.unprotected()) + Unconfigured server: the probe below is client-built plaintext state that must + reach the outer request's context as-sent - the subject is nested-context + isolation, not the wire seal (no surface here mints state at all).""" + mcp = MCPServer() seen_responses: list[InputResponses | None] = [] seen_state: list[str | None] = [] diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py index 2a12a775c..ee4cd0e44 100644 --- a/tests/server/test_request_state.py +++ b/tests/server/test_request_state.py @@ -380,7 +380,8 @@ def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: """SDK-defined: there is no implicit default protection — a policy must name - its codec (or opt out via unprotected()), so the bare constructor fails.""" + its codec, so the bare constructor fails. (Going without protection is spelled + by not configuring `request_state_security=` at all, not by an empty policy.)""" with pytest.raises(ValueError) as exc: RequestStateSecurity() assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") @@ -424,30 +425,17 @@ def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None: def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None: - """SDK-defined: ephemeral() is real protection (not an opt-out) under a key - held only by its own process — so a sibling ephemeral() instance rejects its - tokens, the documented single-process limitation.""" + """SDK-defined: ephemeral() is real protection under a key held only by its + own process — so a sibling ephemeral() instance rejects its tokens, the + documented single-process limitation.""" first = RequestStateSecurity.ephemeral() second = RequestStateSecurity.ephemeral() - assert first.is_unprotected is False - assert first.codec is not None - assert second.codec is not None token = first.codec.seal(_PAYLOAD) assert first.codec.unseal(token) == _PAYLOAD with pytest.raises(InvalidRequestState): second.codec.unseal(token) -def test_an_unprotected_policy_has_no_codec_no_principal_binding_and_no_audience() -> None: - """SDK-defined: unprotected() is the explicit opt-out — is_unprotected is - True and there is no codec, principal binding, or audience to apply.""" - security = RequestStateSecurity.unprotected() - assert security.is_unprotected is True - assert security.codec is None - assert security.bind_principal is None - assert security.audience is None - - def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None: """SDK-defined: `audience` is stored as given for the boundary to stamp and verify; the default None leaves the decision to the server tier's `default_audience`.""" diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 1838ab80f..647c1e600 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -15,7 +15,6 @@ import anyio import pytest -from inline_snapshot import snapshot from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, @@ -129,11 +128,11 @@ def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: def _manual_server( - security: RequestStateSecurity, *, state: str = "awaiting-confirm", name: str = "manual" + security: RequestStateSecurity | None, *, state: str = "awaiting-confirm", name: str = "manual" ) -> tuple[MCPServer, list[str | None]]: """An MCPServer with one manual MRTR tool: round 1 asks, the retry records the - restored `ctx.request_state` and completes. `name` is also the boundary's default - audience.""" + echoed `ctx.request_state` and completes. With `security`, `name` is also the + boundary's default audience; with None no boundary is installed at all.""" seen: list[str | None] = [] mcp = MCPServer(name, request_state_security=security) @@ -657,81 +656,53 @@ async def wizard(ctx: Context) -> str | InputRequiredResult: assert (claims_one["iat"], claims_two["iat"]) == (int(_T0), int(_T0) + 5) -# -- unconfigured boundary (lowlevel tier, no startup gate) ---------------------------- +# -- unconfigured servers: plaintext passthrough (the unprotected posture) ------------- -async def test_an_unconfigured_boundary_rejects_inbound_request_state_before_the_handler() -> None: - """Spec-mandated fail-safe (basic/patterns/mrtr server requirement 5): a server that - never minted state rejects any inbound echo with the frozen error before the handler - runs; a request without `requestState` is untouched.""" - calls: list[str] = [] - - async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: - calls.append(params.name) - return CallToolResult(content=[TextContent(text="ran")]) - - server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) - server.middleware.append(RequestStateBoundary(None)) +async def test_an_unconfigured_mcpserver_passes_request_state_through_verbatim() -> None: + """SDK-defined: an MCPServer constructed without `request_state_security=` installs + no boundary — the deliberate unprotected posture (the spec MAY omit protection when + tampering can cause nothing worse than request failure). The wire carries exactly + the plaintext the manual handler wrote, a verbatim echo is accepted, and the flow + completes.""" + plaintext = "plain-wizard-state" + mcp, seen = _manual_server(None, state=plaintext) with anyio.fail_after(5): - async with Client(server) as client: - with pytest.raises(MCPError) as exc: - await client.session.call_tool("t", {}, request_state="v1.forged", allow_input_required=True) - assert calls == [] - ok = await client.session.call_tool("t", {}) + async with Client(mcp) as client: + first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state == plaintext + second = await _retry(client, "deploy", {"env": "prod"}, plaintext) - _assert_frozen_rejection(exc) - assert isinstance(ok, CallToolResult) - assert calls == ["t"] + assert isinstance(second, CallToolResult) + assert seen == [plaintext] -async def test_an_unconfigured_boundary_answers_outbound_state_with_internal_error_and_logs_remediation( - caplog: pytest.LogCaptureFixture, -) -> None: - """SDK-defined fail-safe: an unconfigured boundary never forwards plaintext state — - the request fails as a bare internal error and the full remediation (and nothing - secret) goes to the server log.""" +async def test_a_boundary_free_lowlevel_server_passes_request_state_through_verbatim() -> None: + """SDK-defined: the lowlevel tier has no implicit protection — without a + `RequestStateBoundary` in `Server.middleware`, `requestState` crosses the wire as + the handler's plaintext and the echo reaches the handler as sent (the no-middleware + control for the one-line-append test above).""" + plaintext = "lowlevel-plain-round-1" + seen: list[str | None] = [] - async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> InputRequiredResult: - return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="oops-plaintext") + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Proceed?")}, request_state=plaintext) + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="done")]) - server = Server("srv", on_call_tool=call_tool) - server.middleware.append(RequestStateBoundary(None)) + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) with anyio.fail_after(5): async with Client(server) as client: - with pytest.raises(MCPError) as exc: - await client.session.call_tool("t", {}, allow_input_required=True) - assert exc.value.error.code == INTERNAL_ERROR - assert exc.value.error.message == "Internal error" - - errors = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.ERROR] - assert len(errors) == 1 - assert errors[0].getMessage() == snapshot( - "handler for tools/call returned an InputRequiredResult with requestState, but no " - "request_state_security is configured on this server; refusing to send unprotected " - "state. Pass request_state_security=RequestStateSecurity(...) to MCPServer " - "(or .ephemeral() for single-process, or .unprotected() to accept the risk)." - ) - assert "oops-plaintext" not in caplog.text - - -# -- explicit opt-out ------------------------------------------------------------------ - - -async def test_unprotected_mode_passes_request_state_through_verbatim() -> None: - """SDK-defined: `RequestStateSecurity.unprotected()` is the spec's explicit opt-out - (MAY omit protection when tampering can cause nothing worse than request failure) — - the wire carries exactly the handler's plaintext and a verbatim echo is accepted.""" - plaintext = "plain-wizard-state" - mcp, seen = _manual_server(RequestStateSecurity.unprotected(), state=plaintext) - - with anyio.fail_after(5): - async with Client(mcp) as client: - first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + first = await client.session.call_tool("t", {}, allow_input_required=True) assert isinstance(first, InputRequiredResult) assert first.request_state == plaintext - second = await _retry(client, "deploy", {"env": "prod"}, plaintext) + second = await _retry(client, "t", {}, plaintext) assert isinstance(second, CallToolResult) assert seen == [plaintext] @@ -766,17 +737,19 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam @pytest.mark.parametrize( - "security", + "install_boundary", [ - pytest.param(RequestStateSecurity(keys=[_KEY]), id="configured"), - pytest.param(None, id="unconfigured"), + pytest.param(True, id="boundary-installed"), + pytest.param(False, id="no-boundary"), ], ) -async def test_an_explicit_null_request_state_is_treated_as_absent(security: RequestStateSecurity | None) -> None: +async def test_an_explicit_null_request_state_is_treated_as_absent(install_boundary: bool) -> None: """SDK-defined (spec-aligned): an explicit `"requestState": null` is the field's absence — a fresh flow, not presented state. The reject-MUST governs PRESENTED state, and stripping the field is already in any client's power, so the handler runs and - sees None — on a configured server and on an unconfigured boundary alike.""" + sees None — through an installed boundary (which verifies only presented state) and + on a boundary-free server (where the null parses straight to the model's None) + alike.""" seen: list[str | None] = [] async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: @@ -784,7 +757,8 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam return CallToolResult(content=[TextContent(text="ran")]) server = Server("srv", on_call_tool=call_tool) - server.middleware.append(RequestStateBoundary(security)) + if install_boundary: + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) async with connected_runner(server) as (client, _): result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": None}) @@ -793,15 +767,16 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam assert seen == [None] -# -- off-set methods: requestState has exactly three legal carriers --------------------- +# -- boundary scope: only the three carrier methods are touched ------------------------- -async def test_inbound_request_state_on_a_non_mrtr_method_is_rejected_before_dispatch() -> None: - """Spec-aligned fail-closed: only tools/call, prompts/get, and resources/read may - carry `requestState`, so a custom (extension-style) method or any other spec method - presenting one is answered with the frozen rejection before any unseal or handler - dispatch — forged state can never be laundered through a method the claims check - does not cover.""" +async def test_inbound_request_state_on_a_non_carrier_method_passes_through_unverified() -> None: + """SDK-defined boundary scope: only tools/call, prompts/get, and resources/read are + multi-round-trip carriers, so the boundary never acts on any other method — a + `requestState` member a client places in a custom (extension-style) method's params + reaches the handler exactly as sent, never unsealed and never verified. Such a value + is outside the multi-round-trip protocol and must not be trusted by whatever handles + it.""" calls: list[str] = [] async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> dict[str, Any]: @@ -813,50 +788,36 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) async with connected_runner(server) as (client, _): - for method in ("example/mrtr", "tools/list"): - with pytest.raises(MCPError) as exc: - await client.send_raw_request(method, {"requestState": "FORGED-BY-CLIENT"}) - _assert_frozen_rejection(exc) - assert calls == [] - ok = await client.send_raw_request("example/mrtr", {}) # no state: dispatch is untouched + ok = await client.send_raw_request("example/mrtr", {"requestState": "CLIENT-SENT-VALUE"}) + fresh = await client.send_raw_request("example/mrtr", {}) assert ok == {"resultType": "complete"} - assert calls == ["fresh"] + assert fresh == {"resultType": "complete"} + assert calls == ["CLIENT-SENT-VALUE", "fresh"] -async def test_outbound_request_state_on_a_non_mrtr_method_is_an_internal_error_with_logged_remediation( - caplog: pytest.LogCaptureFixture, -) -> None: - """Spec-aligned fail-closed: the spec restricts InputRequiredResult to the three MRTR - carriers, so a custom method minting `requestState` is a server bug — the wire gets a - bare internal error (never plaintext, never a sealed token) and the remediation goes - to the server log.""" +async def test_outbound_request_state_on_a_non_carrier_method_is_not_sealed() -> None: + """SDK-defined boundary scope: an input_required-shaped result on a custom method is + outside the multi-round-trip protocol, so an installed boundary leaves its + `requestState` exactly as the handler wrote it — no sealing, no claims envelope + (the carrier methods' seal is pinned by the end-to-end tests above).""" async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: - return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="ext-secret-plaintext") + return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="ext-handler-plaintext") server = Server("srv", on_list_tools=_list_tools) server.add_request_handler("example/mrtr", _CustomMethodParams, custom) server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) async with connected_runner(server) as (client, _): - with pytest.raises(MCPError) as exc: - await client.send_raw_request("example/mrtr", {}) - - assert exc.value.error.code == INTERNAL_ERROR - assert exc.value.error.message == "Internal error" - errors = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.ERROR] - assert len(errors) == 1 - assert errors[0].getMessage() == snapshot( - "handler for example/mrtr returned an input_required result carrying requestState, but the spec " - "restricts InputRequiredResult to tools/call, prompts/get, and resources/read; extension " - "and custom methods must not mint requestState. Refusing to send it." - ) - assert "ext-secret-plaintext" not in caplog.text + result = await client.send_raw_request("example/mrtr", {}) + + assert result["resultType"] == "input_required" + assert result["requestState"] == "ext-handler-plaintext" async def test_an_off_set_input_required_result_without_state_passes_through_untouched() -> None: - """SDK-defined: an input_required-shaped result on a non-MRTR method that mints no + """SDK-defined: an input_required-shaped result on a non-carrier method that mints no `requestState` is not this module's concern — it crosses the boundary unmodified.""" async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: diff --git a/tests/server/test_request_state_gate.py b/tests/server/test_request_state_gate.py index f5f318bff..99d4d393e 100644 --- a/tests/server/test_request_state_gate.py +++ b/tests/server/test_request_state_gate.py @@ -2,12 +2,15 @@ (`mcp.server.request_state` + the `MCPServer` wiring). Every test here is synchronous registration-time behavior: no Client, no -connection, no event loop. The gate is SDK-defined product policy, deliberately -stricter than the spec's conditional MUST (basic/patterns/mrtr, server -requirements 4-5 apply only when state influences authorization, resource -access, or business logic): the SDK cannot see what authors put in their state, -so every MRTR-capable registration must pick a `RequestStateSecurity` posture -up front, before any client can connect. +connection, no event loop. The gate is resolver-only: a `Resolve(...)` tool's +requestState carries elicited answers — business inputs the SDK itself authors — +so the spec's integrity requirement (basic/patterns/mrtr, server requirements +4-5) is never optional for it, and registering one on a server constructed +without `request_state_security=` fails up front, before any client can +connect. Manual `InputRequiredResult` surfaces (tools, prompts, resource +templates) are not gated: their state is author-written, and an unconfigured +server deliberately passes it through as plaintext (the boundary tests pin that +posture). """ from typing import Annotated, Any @@ -32,11 +35,12 @@ async def _provide_login(ctx: Context) -> str: ... -# Resolver-driven tool (RESOLVER capability): +# Resolver-driven tool (the only gated capability): async def _deploy(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str: ... -# Manual-MRTR tool, prompt, and resource template (DECLARED_MANUAL capability): +# Manual-MRTR tool, prompt, and resource template (declared InputRequiredResult +# returns; not gated): async def _confirm_deploy(target: str) -> str | InputRequiredResult: ... @@ -60,10 +64,10 @@ async def _plain_template(id: str) -> str: ... def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> None: - """SDK-defined product bar (stricter than the spec's conditional MUST, mrtr server - reqs 4-5): a `Resolve(...)` tool mints requestState, so registering it on a server - constructed without `request_state_security=` raises at the `@mcp.tool()` call with - the full teaching text.""" + """SDK-defined: a `Resolve(...)` tool's requestState carries elicited answers — + business inputs, squarely inside the spec's integrity MUST (mrtr server reqs 4-5) — + so registering it on a server constructed without `request_state_security=` raises + at the `@mcp.tool()` call with the full teaching text.""" mcp = MCPServer("gate") with pytest.raises(ValueError) as excinfo: @@ -71,9 +75,10 @@ def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> N assert str(excinfo.value) == snapshot("""\ Tool 'deploy' uses Resolve(...) parameters, so this server mints a -requestState that round-trips through the client. The MCP spec requires that state -to be integrity-protected, and rejected when verification fails, whenever it can -influence authorization, resource access, or business logic. Configure protection: +requestState carrying elicited answers that round-trips through the client. The +MCP spec requires that state to be integrity-protected, and rejected when +verification fails, whenever it can influence authorization, resource access, +or business logic. Configure protection: MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) One or more shared secret keys (>= 32 bytes each). Required when a retry @@ -86,10 +91,8 @@ def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> N (stdio, one HTTP worker): state minted before a restart, or by another instance, is rejected and the client must restart the flow. - MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) - No protection. Only valid when tampering can cause nothing worse than a - failed request - not available for Resolve(...) tools, whose state - carries elicited answers. +For your own crypto (a KMS, an existing token service), pass +RequestStateSecurity(codec=...). Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ """) @@ -107,17 +110,6 @@ def test_constructor_supplied_resolver_tool_bypasses_add_tool_but_is_still_rejec assert "deploy" in str(excinfo.value) -def test_constructor_supplied_declared_manual_tool_is_rejected() -> None: - """SDK-defined: the constructor scan also derives DECLARED_MANUAL — a hand-supplied - Tool whose function declares an InputRequiredResult return is rejected at - `MCPServer(tools=[...])`, naming it.""" - with pytest.raises(ValueError) as excinfo: - MCPServer("gate", tools=[Tool.from_function(_confirm_deploy, name="confirm_deploy")]) - - assert "confirm_deploy" in str(excinfo.value) - assert "declares an InputRequiredResult return" in str(excinfo.value) - - def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: """SDK-defined: the constructor scan judges a hand-built Tool by its stored `resolved_params` — the authority that actually drives resolution at call time — @@ -132,10 +124,10 @@ def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: - """SDK-defined: the decorator gate stands aside for a Resolve+InputRequiredResult - combination because `Tool.from_function` rejects it with its own error; a hand-built - Tool has no such backstop, so the constructor scan gates the combo as RESOLVER - (stored `resolved_params` decide) instead of silently admitting it.""" + """SDK-defined: a hand-built Tool carrying both stored `resolved_params` and an fn + that declares an InputRequiredResult return (a combination `Tool.from_function` + rejects with its own error) is still judged by its stored resolver authority — the + constructor scan raises the resolver gate instead of silently admitting it.""" tool = Tool.from_function(_deploy, name="combo").model_copy(update={"fn": _confirm_deploy}) with pytest.raises(ValueError) as excinfo: @@ -144,126 +136,46 @@ def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: assert "uses Resolve(...) parameters" in str(excinfo.value) -def test_declared_manual_tool_without_security_is_rejected_naming_the_declared_return() -> None: - """SDK-defined: a tool annotated `-> str | InputRequiredResult` (manual MRTR, no - Resolve) also mints requestState, so unconfigured registration raises with the - DECLARED_MANUAL variant text naming the declared return.""" - mcp = MCPServer("gate") - - with pytest.raises(ValueError) as excinfo: - mcp.tool(name="confirm_deploy")(_confirm_deploy) - - assert str(excinfo.value) == snapshot("""\ -Tool 'confirm_deploy' declares an InputRequiredResult return, so this server mints a -requestState that round-trips through the client. The MCP spec requires that state -to be integrity-protected, and rejected when verification fails, whenever it can -influence authorization, resource access, or business logic. Configure protection: - - MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) - One or more shared secret keys (>= 32 bytes each). Required when a retry - can reach a different instance (multi-worker or load-balanced HTTP). - keys[0] seals, every key verifies; rotation is - [old, new] -> [new, old] -> [new], each phase fully rolled out first. - - MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) - A key generated at process start. Single-process deployments only - (stdio, one HTTP worker): state minted before a restart, or by another - instance, is rejected and the client must restart the flow. - - MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) - No protection. Only valid when tampering can cause nothing worse than a - failed request - not available for Resolve(...) tools, whose state - carries elicited answers. - -Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ -""") - - -def test_declared_manual_prompt_without_security_is_rejected_at_the_decorator_call() -> None: - """SDK-defined: prompts/get is an MRTR carrier too, so a prompt function declaring - `-> str | InputRequiredResult` is rejected at `@mcp.prompt()` on an unconfigured - server.""" +def test_decorator_combo_fn_on_an_unconfigured_server_raises_the_resolver_gate_error() -> None: + """SDK-defined: the `add_tool` gate scans the function before `Tool.from_function` + runs, so a function combining `Resolve(...)` parameters with a declared + `InputRequiredResult` return raises the resolver-security error on an unconfigured + server; configuring security then surfaces `Tool.from_function`'s own + `InvalidSignature` for the combination (pinned in test_resolve.py).""" mcp = MCPServer("gate") - with pytest.raises(ValueError) as excinfo: - mcp.prompt(name="briefing")(_briefing) - - assert str(excinfo.value) == snapshot("""\ -Prompt 'briefing' declares an InputRequiredResult return, so this server mints a -requestState that round-trips through the client. The MCP spec requires that state -to be integrity-protected, and rejected when verification fails, whenever it can -influence authorization, resource access, or business logic. Configure protection: - - MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) - One or more shared secret keys (>= 32 bytes each). Required when a retry - can reach a different instance (multi-worker or load-balanced HTTP). - keys[0] seals, every key verifies; rotation is - [old, new] -> [new, old] -> [new], each phase fully rolled out first. - - MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) - A key generated at process start. Single-process deployments only - (stdio, one HTTP worker): state minted before a restart, or by another - instance, is rejected and the client must restart the flow. - - MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) - No protection. Only valid when tampering can cause nothing worse than a - failed request - not available for Resolve(...) tools, whose state - carries elicited answers. - -Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ -""") - - -def test_declared_manual_prompt_via_add_prompt_is_rejected_the_same_way() -> None: - """SDK-defined: `add_prompt(Prompt.from_function(...))` is the same funnel the - decorator uses, so it trips the same gate and names the prompt.""" - mcp = MCPServer("gate") + async def combo(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str | InputRequiredResult: ... with pytest.raises(ValueError) as excinfo: - mcp.add_prompt(Prompt.from_function(_briefing, name="briefing")) - - assert "briefing" in str(excinfo.value) - + mcp.tool()(combo) -def test_declared_manual_resource_template_without_security_is_rejected_at_the_decorator_call() -> None: - """SDK-defined: resources/read is an MRTR carrier for templates, so a template - function declaring `-> str | InputRequiredResult` is rejected at - `@mcp.resource("data://{id}")` on an unconfigured server.""" - mcp = MCPServer("gate") - - with pytest.raises(ValueError) as excinfo: - mcp.resource("data://{id}")(_record) + assert "uses Resolve(...) parameters" in str(excinfo.value) - assert str(excinfo.value) == snapshot("""\ -Resource template 'data://{id}' declares an InputRequiredResult return, so this server mints a -requestState that round-trips through the client. The MCP spec requires that state -to be integrity-protected, and rejected when verification fails, whenever it can -influence authorization, resource access, or business logic. Configure protection: - MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) - One or more shared secret keys (>= 32 bytes each). Required when a retry - can reach a different instance (multi-worker or load-balanced HTTP). - keys[0] seals, every key verifies; rotation is - [old, new] -> [new, old] -> [new], each phase fully rolled out first. +def test_declared_manual_surfaces_register_cleanly_on_an_unconfigured_server() -> None: + """SDK-defined: declared manual surfaces — a tool, prompt, or resource template + annotated `-> ... | InputRequiredResult` — are NOT gated: their state is + author-written, so every funnel (decorator, constructor `tools=`, `add_prompt`) + registers cleanly on a server with no `request_state_security=`. The unconfigured + server passes their state through as plaintext (pinned in the boundary tests).""" + mcp = MCPServer("gate", tools=[Tool.from_function(_confirm_deploy, name="ctor_confirm_deploy")]) - MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) - A key generated at process start. Single-process deployments only - (stdio, one HTTP worker): state minted before a restart, or by another - instance, is rejected and the client must restart the flow. - - MCPServer(..., request_state_security=RequestStateSecurity.unprotected()) - No protection. Only valid when tampering can cause nothing worse than a - failed request - not available for Resolve(...) tools, whose state - carries elicited answers. + mcp.tool(name="confirm_deploy")(_confirm_deploy) + mcp.prompt(name="briefing")(_briefing) + mcp.add_prompt(Prompt.from_function(_briefing, name="briefing_via_add")) + mcp.resource("data://{id}")(_record) -Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ -""") + assert mcp._tool_manager.get_tool("ctor_confirm_deploy") is not None + assert mcp._tool_manager.get_tool("confirm_deploy") is not None + assert mcp._prompt_manager.get_prompt("briefing") is not None + assert mcp._prompt_manager.get_prompt("briefing_via_add") is not None + assert [t.uri_template for t in mcp._resource_manager.list_templates()] == ["data://{id}"] def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> None: - """SDK-defined: with `request_state_security=` supplied, the exact registrations - the gate rejects all succeed across every funnel (constructor `tools=`, tool and - prompt and resource decorators, `add_prompt`).""" + """SDK-defined: with `request_state_security=` supplied, the resolver tools the gate + rejects register cleanly — and so does every other MRTR surface, across every funnel + (constructor `tools=`, tool and prompt and resource decorators, `add_prompt`).""" mcp = MCPServer( "gate", request_state_security=RequestStateSecurity.ephemeral(), @@ -281,52 +193,8 @@ def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> N assert [t.uri_template for t in mcp._resource_manager.list_templates()] == ["data://{id}"] -def test_unprotected_refuses_resolver_tools_at_registration() -> None: - """SDK-defined: `unprotected()` is not a lawful opt-out for `Resolve(...)` tools — - their state carries elicited answers, which are business inputs — so registration - still raises, with text pointing at `keys=`/`ephemeral()`.""" - mcp = MCPServer("gate", request_state_security=RequestStateSecurity.unprotected()) - - with pytest.raises(ValueError) as excinfo: - mcp.tool(name="deploy")(_deploy) - - assert str(excinfo.value) == snapshot("""\ -Tool 'deploy' uses Resolve(...) parameters, so this server mints a -requestState that round-trips through the client. The MCP spec requires that state -to be integrity-protected, and rejected when verification fails, whenever it can -influence authorization, resource access, or business logic. Configure protection: - - MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) - One or more shared secret keys (>= 32 bytes each). Required when a retry - can reach a different instance (multi-worker or load-balanced HTTP). - keys[0] seals, every key verifies; rotation is - [old, new] -> [new, old] -> [new], each phase fully rolled out first. - - MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) - A key generated at process start. Single-process deployments only - (stdio, one HTTP worker): state minted before a restart, or by another - instance, is rejected and the client must restart the flow. - - Resolve(...) tools cannot opt out: their requestState carries elicited - answers, which are business inputs. Use keys=[...] or .ephemeral(). - -Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ -""") - - -def test_unprotected_is_a_lawful_opt_out_for_declared_manual_tools() -> None: - """SDK-defined: a manual `-> str | InputRequiredResult` flow may hold state the - spec's exception covers (tampering can cause nothing worse than request failure), - so `unprotected()` lets it register; the author has explicitly accepted the risk.""" - mcp = MCPServer("gate", request_state_security=RequestStateSecurity.unprotected()) - - mcp.tool(name="confirm_deploy")(_confirm_deploy) - - assert mcp._tool_manager.get_tool("confirm_deploy") is not None - - def test_mrtr_free_registrations_need_no_security_configuration() -> None: - """SDK-defined: the gate keys on MRTR capability, so plain tools (decorator and + """SDK-defined: the gate keys on `Resolve(...)` usage, so plain tools (decorator and constructor-supplied), prompts, and resources register on an unconfigured server exactly as before — this pins the gate against over-firing.""" mcp = MCPServer("gate", tools=[Tool.from_function(_plain_tool, name="ctor_plain_tool")]) From 6c0b2abedba63df09ca5a7c0a9af7170b6adafae Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:14:55 +0000 Subject: [PATCH 4/8] Tighten comments and docstrings across the requestState change Comments and docstrings in the new code carried far more prose than they earned. Cut the comment and docstring volume by more than half: docstrings now lead with a single-sentence summary and keep only contracts a reader cannot infer (codec implementer requirements, the key rotation procedure, Raises sections), inline comments are one line and exist only where the code cannot speak for itself, and development narration is gone. Typographic characters in the touched prose were replaced by restructuring the sentences. No executable code changed. --- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 32 +-- docs/migration.md | 2 +- docs/tutorial/dependencies.md | 2 +- docs_src/mrtr/tutorial005.py | 2 +- .../mcp_everything_server/server.py | 7 +- examples/stories/README.md | 2 +- examples/stories/mrtr/README.md | 34 +-- examples/stories/mrtr/client.py | 13 +- examples/stories/mrtr/server.py | 12 +- examples/stories/mrtr/server_lowlevel.py | 4 +- examples/stories/refund_desk/README.md | 2 +- examples/stories/refund_desk/server.py | 3 +- src/mcp-types/mcp_types/methods.py | 9 +- src/mcp/client/client.py | 6 +- src/mcp/server/mcpserver/resolve.py | 31 +-- src/mcp/server/mcpserver/server.py | 34 +-- src/mcp/server/request_state.py | 228 +++++------------- tests/docs_src/test_mrtr.py | 5 +- tests/server/mcpserver/test_resolve.py | 77 ++---- tests/server/mcpserver/test_server.py | 18 +- tests/server/test_request_state.py | 123 +++------- tests/server/test_request_state_boundary.py | 219 +++++------------ tests/server/test_request_state_gate.py | 86 ++----- tests/types/test_methods.py | 6 +- 25 files changed, 284 insertions(+), 675 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index a5fc1be71..8accd4e22 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...])))` — one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...])))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index ff8fbda54..a8fa256df 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -40,7 +40,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT ``` * The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. -* Nothing extra is required to register this form — only `Resolve(...)` tools force a `request_state_security=` choice at construction. But if your function sets a `request_state`, what the client echoes back is client-supplied input; **[Protecting `requestState`](#protecting-requeststate)** below covers why you should configure protection anyway, and what you get when you do. +* Nothing extra is required to register this form: only `Resolve(...)` tools force a `request_state_security=` choice at construction. But if your function sets a `request_state`, what the client echoes back is client-supplied input; **[Protecting `requestState`](#protecting-requeststate)** below covers why you should configure protection anyway, and what you get when you do. * An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. @@ -87,9 +87,9 @@ Drop to the underlying session, where `allow_input_required=True` hands you the ## Protecting `requestState` -Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs — writing it down across processes is exactly what the previous section blessed — so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. +Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. -The SDK requires a protection choice exactly where it authors the state itself: registering a `Resolve(...)` tool refuses to construct until you pass `request_state_security=`, because resolver state carries elicited answers the server will later trust. For state **you** build — returning `InputRequiredResult` from a tool, prompt, or resource template — nothing is required. But the echoed value is attacker-controlled input all the same, so you should configure protection there too: with `request_state_security=` set, your hand-built state is sealed and verified by the same machinery with zero code changes — write plaintext, read plaintext. Without it, your state crosses the wire exactly as written, and the spec's integrity requirement is yours to satisfy — running unconfigured is a risk you accept, not a default the SDK chose for you. +The SDK requires a protection choice exactly where it authors the state itself: registering a `Resolve(...)` tool refuses to construct until you pass `request_state_security=`, because resolver state carries elicited answers the server will later trust. For state **you** build by returning `InputRequiredResult` from a tool, prompt, or resource template, nothing is required. But the echoed value is attacker-controlled input all the same, so you should configure protection there too: with `request_state_security=` set, your hand-built state is sealed and verified by the same machinery with zero code changes. You write plaintext and read plaintext. Without it, your state crosses the wire exactly as written, and the spec's integrity requirement is yours to satisfy: running unconfigured is a risk you accept, not a default the SDK chose for you. There are two configurations: @@ -103,20 +103,20 @@ mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]) mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral()) ``` -* `keys=[...]` is the built-in encrypting codec under your secret(s). Required whenever a retry can reach a **different instance** — multi-worker or load-balanced HTTP — because every instance must be able to verify what any sibling minted. -* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over — right for a single process, wrong for a fleet. The resolver tutorials in these docs use it for that reason. -* For your own crypto — a KMS, an existing token service — pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. +* `keys=[...]` is the built-in encrypting codec under your secret(s). Required whenever a retry can reach a **different instance** (multi-worker or load-balanced HTTP), because every instance must be able to verify what any sibling minted. +* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over: right for a single process, wrong for a fleet. The resolver tutorials in these docs use it for that reason. +* For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. ### What the seal carries With either built-in configuration, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: * **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. -* **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK — a fronting proxy — or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. +* **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. * **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails. -* **The exact question asked.** A recorded resolver answer is pinned to the rendered question the client was shown. Redeploy with a reworded message or a changed schema and the server re-asks instead of reusing a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data — a message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. +* **The exact question asked.** A recorded resolver answer is pinned to the rendered question the client was shown. Redeploy with a reworded message or a changed schema and the server re-asks instead of reusing a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. -All of that is the SDK's job — not yours, and not the codec's if you bring your own. +All of that is the SDK's job, not yours, and not the codec's if you bring your own. ### Rotating keys @@ -134,31 +134,31 @@ Keys are scoped to one service. The sealed envelope also carries the server's na ### Bring your own crypto -`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS — unwrap a data key once at startup, then keep the per-token crypto local: +`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local: ```python title="server.py" hl_lines="12 29-30 33" --8<-- "docs_src/mrtr/tutorial005.py" ``` -TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity — tampered means raise — and, ideally, confidentiality. +TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity (tampered means raise) and, ideally, confidentiality. ### When verification fails -Every inbound failure — tampered, expired, replayed against a different request or principal, sealed under a key this server doesn't know — gets the same answer: +Every inbound failure, whether tampered, expired, replayed against a different request or principal, or sealed under a key this server doesn't know, gets the same answer: ```json {"code": -32602, "message": "Invalid or expired requestState"} ``` -One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Verification is a configured server's behavior: with `request_state_security=` set, every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked — including one arriving for a handler that never mints state. Without it, nothing is checked: inbound state reaches your handler exactly as the client sent it. +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Verification is a configured server's behavior: with `request_state_security=` set, every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. Without it, nothing is checked: inbound state reaches your handler exactly as the client sent it. ### Hand-built state -A `request_state` you set yourself — returning `InputRequiredResult` from a tool, prompt, or resource-template function — never requires `request_state_security=`. Configure it anyway and your hand-built state is sealed and verified by the same machinery, with zero code changes: write plaintext, read plaintext, and every binding above applies. Don't, and the state crosses the wire exactly as written — whatever comes back is the client's word, and the spec's integrity requirement is yours to satisfy before you act on it. +A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) never requires `request_state_security=`. Configure it anyway and your hand-built state is sealed and verified by the same machinery, with zero code changes: write plaintext, read plaintext, and every binding above applies. Don't, and the state crosses the wire exactly as written: whatever comes back is the client's word, and the spec's integrity requirement is yours to satisfy before you act on it. The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. -The low-level `Server` is the no-batteries tier: nothing is required at construction and nothing is sealed until you append the boundary yourself — one line, shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. +The low-level `Server` is the no-batteries tier: nothing is required at construction and nothing is sealed until you append the boundary yourself. The one-line opt-in is shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. ## A 2026-07-28 result @@ -184,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. -* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and — when the request carries auth the SDK validated, or `bind_principal=` supplies your own identity signal — the authenticated client (**[Protecting `requestState`](#protecting-requeststate)**). +* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated client when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 4d2cbf6e7..4d07bdc7a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -435,7 +435,7 @@ mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemer Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). -On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote — sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too. +On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote. Sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too. ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index f79fa636d..7b239106f 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -15,7 +15,7 @@ Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: * `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument. * Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see. * The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble. -* `request_state_security=` is the one piece of ceremony. A tool with resolvers can pause mid-call to ask the user — that's later in this chapter — and resuming sends a token through the client, so the SDK makes you choose how that token is protected before it will build the server. `ephemeral()`, a key generated at process start, is the right choice for a single-process server like this one; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** has the full story. +* `request_state_security=` is the one piece of ceremony. A tool with resolvers can pause mid-call to ask the user (that's later in this chapter), and resuming sends a token through the client, so the SDK makes you choose how that token is protected before it will build the server. `ephemeral()`, a key generated at process start, is the right choice for a single-process server like this one; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** has the full story. !!! info If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what diff --git a/docs_src/mrtr/tutorial005.py b/docs_src/mrtr/tutorial005.py index 05155ae68..af2d76054 100644 --- a/docs_src/mrtr/tutorial005.py +++ b/docs_src/mrtr/tutorial005.py @@ -10,7 +10,7 @@ def unwrap_data_key() -> bytes: - """One KMS call at process start - kms.decrypt(CiphertextBlob=...) - then every token is local crypto.""" + """One KMS call at process start, kms.decrypt(CiphertextBlob=...); every token after that is local crypto.""" return os.urandom(32) # stand-in for the unwrapped 32-byte data key diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 991f14d7e..218188f50 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -97,8 +97,7 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event # Create event store for SSE resumability (SEP-1699) event_store = InMemoryEventStore() -# Fixed key for the conformance fixture; a real deployment would load a shared secret. -# RequestStateSecurity requires keys of at least 32 bytes — this one is 43. +# Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret. _REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key" mcp = MCPServer( @@ -503,9 +502,7 @@ async def test_input_required_result_multi_round(ctx: Context) -> str | InputReq async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult: """Tests that the server rejects a tampered requestState echo. - The handler writes and reads plaintext state; sealing and tamper rejection - happen in the SDK's request-state boundary, so a tampered echo never - reaches this code. + The handler stays plaintext; tamper rejection happens in the SDK's request-state boundary. """ if ctx.request_state is None: confirm = ElicitRequest( diff --git a/examples/stories/README.md b/examples/stories/README.md index 52facf1d9..7b71c1a63 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -128,7 +128,7 @@ opens with a banner saying what replaces it. | [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current | | **— feature stories —** | | | | [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | -| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and `RequestStateSecurity` sealing `requestState` (tamper → one frozen error) | current | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and `RequestStateSecurity` sealing `requestState` (a tampered echo gets one frozen error) | current | | [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | | [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | | [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 86ba02373..5a735c32b 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -3,12 +3,12 @@ Multi-round tool result: on the 2026-07-28 protocol a tool that needs user input mid-call **returns** `resultType: "input_required"` with embedded `inputRequests` and an opaque `requestState`, instead of pushing a -server→client request. The client fulfils the embedded requests and retries the +server-to-client request. The client fulfils the embedded requests and retries the original `tools/call` carrying `inputResponses` and the echoed `requestState`. The story shows both the `Client` auto-loop (one `await call_tool`, callbacks -fired transparently) and a manual `client.session` loop (the persistable form) -— and, because `requestState` round-trips through the client, the security -surface that protects it: the server is constructed with +fired transparently) and a manual `client.session` loop (the persistable +form). Because `requestState` round-trips through the client, it also shows +the security surface that protects it: the server is constructed with `request_state_security=RequestStateSecurity.ephemeral()`, handlers keep writing plaintext state, and the SDK seals it at the wire boundary. The manual loop tampers with the sealed token to show what a forged echo gets back. @@ -16,7 +16,7 @@ loop tampers with the sealed token to show what a forged echo gets back. ## Run it ```bash -# HTTP — the client self-hosts the server on a free port, runs, then tears it +# HTTP: the client self-hosts the server on a free port, runs, then tears it # down (the InputRequiredResult round-trip is 2026-era only) uv run python -m stories.mrtr.client --http # same, against the lowlevel-API server variant @@ -25,36 +25,36 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## What to look at -- `server.py` `build_server` — the whole security opt-in is one constructor - argument: `request_state_security=RequestStateSecurity.ephemeral()`. - Opting in is this server's choice — only tools with `Resolve(...)` +- `server.py` `build_server`: the whole security opt-in is the single + constructor argument `request_state_security=RequestStateSecurity.ephemeral()`. + Opting in is this server's choice, since only tools with `Resolve(...)` parameters are required to configure protection; a hand-built flow like `deploy` would otherwise send its state across the wire as plaintext. `ephemeral()` generates a key at process start, which is right for a single-process server like this one; a fleet (multi-worker or load-balanced) shares keys with `RequestStateSecurity(keys=[...])` so any instance can verify state another minted. -- `server.py` `deploy` — handlers stay plaintext: the first round returns +- `server.py` `deploy`: handlers stay plaintext. The first round returns `InputRequiredResult(input_requests={...}, request_state="awaiting-confirm")` and the retry asserts `ctx.request_state == "awaiting-confirm"`. The tool never touches the crypto; the boundary seals on the way out and unseals the echo on the way back in. -- `client.py` `main` — the auto-loop is invisible at the call site: +- `client.py` `main`: the auto-loop is invisible at the call site: `Client(target, mode=mode, elicitation_callback=on_elicit)` then `await client.call_tool("deploy", ...)`. The same `on_elicit` callback the legacy push path uses is dispatched for each embedded `inputRequests` entry. -- `client.py` manual block — `client.session.call_tool(..., +- `client.py` manual block: `client.session.call_tool(..., allow_input_required=True)` returns the raw `InputRequiredResult` so `request_state` can be persisted between rounds. The wire value is an opaque - sealed token, **not** the string the server code wrote — the client asserts + sealed token, **not** the string the server code wrote. The client asserts exactly that, then retries with one character of the token flipped and gets the single frozen error every verification failure maps to: `-32602`, `"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`. The specific reason (tampered tag, expiry, wrong request, wrong principal) appears only in the server's log, never on the wire. The untampered token then completes the round normally. -- `server_lowlevel.py` — the lowlevel tier has no construction-time +- `server_lowlevel.py`: the lowlevel tier has no construction-time requirement; the same enforcement is one appended middleware: `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral()))`. @@ -71,12 +71,12 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## Spec -[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results), -[Multi-round-trip requests — security patterns](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr) +[Input required tool results (server features)](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results), +[Multi-round-trip requests (security patterns)](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr) ## See also -`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this -mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the +`legacy_elicitation/` and `sampling/`: the handshake-era push equivalents this +mechanism replaces on the 2026 protocol. `refund_desk/`: resolver DI at the MCPServer tier: the questions a tool can declare instead of pushing by hand (its elicited answers ride in the same sealed `requestState`). diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py index 62d10dc92..7280fd0ae 100644 --- a/examples/stories/mrtr/client.py +++ b/examples/stories/mrtr/client.py @@ -28,18 +28,14 @@ async def main(target: Target, *, mode: str = "auto") -> None: first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True) assert isinstance(first, types.InputRequiredResult) assert first.input_requests is not None and "confirm" in first.input_requests - # The wire request_state is OPAQUE: server.py wrote "awaiting-confirm", but the - # boundary middleware sealed it before it left the server — the plaintext never - # crosses the wire, and the client just echoes the token byte-exact. + # The boundary sealed server.py's plaintext "awaiting-confirm"; the wire token is opaque. token = first.request_state assert token is not None and token != "awaiting-confirm", token responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")} - # Tamper demonstration: flip one character and retry. The token decodes strictly - # canonically, so changing ANY character — including the final one — rejects. - # Every verification failure collapses to ONE frozen wire error; the real reason - # (here: a failed authentication tag) appears only in the server's log. + # Tamper demo: flipping any one character fails verification, and every failure + # maps to one frozen wire error; the real reason appears only in the server log. i = len(token) // 2 tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] try: @@ -57,8 +53,7 @@ async def main(target: Target, *, mode: str = "auto") -> None: else: raise AssertionError("expected MCPError for a tampered requestState") - # The untampered token still completes the round. Decline this time so the path - # diverges from the auto-loop run above. + # The untampered token still completes the round; decline so this path diverges from the auto run. second = await client.session.call_tool( "deploy", {"env": "staging"}, diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py index a8850bc7d..955f0c549 100644 --- a/examples/stories/mrtr/server.py +++ b/examples/stories/mrtr/server.py @@ -13,25 +13,19 @@ def build_server() -> MCPServer: - # requestState round-trips through the client, so the SDK requires a protection - # policy before it lets a tool mint one. ephemeral() = a key generated at process - # start; right for single-process servers like this one. Fleets share keys=[...]. + # requestState round-trips through the client, so minting one requires a protection + # policy. ephemeral() suits single-process servers; fleets share keys=[...]. mcp = MCPServer("mrtr-example", request_state_security=RequestStateSecurity.ephemeral()) @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: responses = ctx.input_responses if responses is None or "confirm" not in responses: - # First round: ask the client to elicit confirmation. The handler writes its - # request_state in PLAINTEXT — the boundary middleware seals it into an opaque - # token on the way out and unseals the echo on the retry, so this code never - # touches the crypto. (client.py proves the wire never carries this string.) ask = ElicitRequest( params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) ) + # The boundary seals this plaintext request_state on the way out and unseals the echo on retry. return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") - # Retry round: the client echoed the sealed token byte-exact; the boundary - # verified it and handed back the plaintext this handler originally wrote. assert ctx.request_state == "awaiting-confirm", ctx.request_state answer = responses["confirm"] if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"): diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py index c71a10bf6..e10f77ba8 100644 --- a/examples/stories/mrtr/server_lowlevel.py +++ b/examples/stories/mrtr/server_lowlevel.py @@ -57,9 +57,7 @@ async def call_tool( return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) - # The lowlevel tier has no construction-time requirement; appending the boundary - # middleware is the whole opt-in, and it is the identical enforcement MCPServer - # installs from its request_state_security= parameter. + # Lowlevel opt-in: append the same boundary middleware MCPServer installs from request_state_security=. server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral())) return server diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 1cfb717bd..70b3d9030 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -32,7 +32,7 @@ uv run python -m stories.refund_desk.client --http `required` are exactly `{order_id, reason}`. The server is constructed with `request_state_security=RequestStateSecurity.ephemeral()` because at 2026 the resolver's elicited answers ride between rounds inside a sealed - `requestState` — see `mrtr/` for the full security walk-through. + `requestState`; see `mrtr/` for the full security walk-through. - `server.py` `refund_scope` — the no-round-trip fast path: a one-line order returns `Scope(full=True)` directly; only a multi-line order returns `Elicit(...)`. The ORD-7001 call completes with zero elicitations. diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py index 0cd8ad2cd..0c9be4da2 100644 --- a/examples/stories/refund_desk/server.py +++ b/examples/stories/refund_desk/server.py @@ -104,8 +104,7 @@ def ask_restock( def build_server() -> MCPServer: - # At 2026 the elicited answers ride between rounds inside requestState; resolver - # tools refuse to register without protection. See mrtr/ for the full story. + # Resolver tools refuse to register without requestState protection; see mrtr/ for the full story. mcp = MCPServer("refund-desk", request_state_security=RequestStateSecurity.ephemeral()) @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 11bdb9b4b..37e114538 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -432,16 +432,11 @@ issubclass(arm, types.InputRequiredResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)) ) ) -"""Methods whose results may be `InputRequiredResult` (the MRTR carriers), derived from `MONOLITH_RESULTS`.""" +"""Methods whose results may be `InputRequiredResult`, derived from `MONOLITH_RESULTS`.""" def is_input_required(result: object) -> TypeGuard[types.InputRequiredResult | dict[str, Any]]: - """True when `result` is an `input_required` interim result, typed or wire-shaped. - - Covers both shapes a server result takes in-process: the `InputRequiredResult` - model, and the serialized wire dict discriminated by `resultType` (any mapping - matches at runtime; the guard claims the SDK's wire-dict shape). - """ + """True when `result` is an `input_required` interim result, typed or wire-shaped.""" if isinstance(result, types.InputRequiredResult): return True return isinstance(result, Mapping) and cast("Mapping[str, Any]", result).get("resultType") == "input_required" diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 6a5f67b2e..de482ce50 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -610,10 +610,8 @@ async def call_tool( `input_required_max_rounds`). To drive the loop yourself — e.g. to persist `request_state` across process restarts — use `client.session.call_tool(..., allow_input_required=True)`. Persisted - state resumes only within the server's constraints: the token expires - after the server's per-round TTL (default 10 minutes), is bound to the - exact original request, and dies with the server's key — an - `ephemeral()` server rejects it after a restart. + state is still subject to the server's TTL, request binding, and key + lifetime; an `ephemeral()` server rejects it after a restart. Args: name: The name of the tool to call. diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 5dcafb4e1..a6b9628cb 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -496,18 +496,11 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio if not res.input_required: return await res.context.elicit(elicit.message, elicit.schema) - # Every recorded outcome - accept, decline, AND cancel - is pinned to the exact - # question it answered: a decline of one wording must not suppress a reworded - # question that reuses the same wire key after a redeploy. The digest is - # computed once per question per round and shared by restore and persist. q = _question_digest(elicit) # A recorded outcome from a prior round is consulted only here, after the body # decided to ask, so a `request_state` entry can never stand in for a resolver's - # own computation. It is honored only for the exact question being asked, and - # accept data is re-validated against the live `Elicit.schema`. A recorded - # outcome wins over a re-sent answer; a stale or invalid entry self-deletes and - # falls through to the fresh answer (or to re-asking). + # own computation. A recorded outcome wins over a re-sent answer. outcome = _restore_outcome(res, key, elicit.schema, q) if outcome is not None: return outcome @@ -611,10 +604,7 @@ class _StateEntry(BaseModel): def _question_digest(elicit: Elicit[Any]) -> str: """Pin an outcome to the exact rendered question the client was shown. - Computed over the rendered ElicitRequest params bytes - the same bytes the - client displayed - so a recorded outcome survives only as long as the - question is byte-identical. A redeploy that rewords the message or changes - the schema re-asks instead of silently reusing a stale answer. + A redeploy that rewords or reshapes a question re-asks it instead of reusing the recorded answer. """ rendered = _elicit_request(elicit).params.model_dump_json(by_alias=True, exclude_none=True) digest = hashlib.sha256(rendered.encode()).digest()[:16] @@ -631,11 +621,9 @@ class _State(BaseModel): def _decode_state(request_state: str | None) -> dict[str, _StateEntry]: """Decode the per-call resolution progress from `request_state`. - The string arrives boundary-authenticated (the middleware only forwards - plaintext this server minted), so anything malformed or version-mismatched - here is inner-format drift within the operator's own fleet - e.g. a rolling - upgrade - where treating it as "no progress yet" and re-asking is exactly - right. + The string arrives boundary-authenticated, so malformed content or a + version mismatch is drift within the operator's own fleet (e.g. a rolling + upgrade) and is treated as "no progress yet". """ if not request_state: return {} @@ -672,12 +660,9 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None: """Restore `key`'s recorded outcome from a prior round, or `None` when absent. - An entry is honored only for the exact question being asked - `q` is the - live question's digest, precomputed by the caller: one pinned to a different - rendered question (the server reworded or reshaped it since the outcome was - recorded), or whose accepted data fails validation against the live - `schema`, is dropped as if no progress was recorded - so the question is - asked again - rather than surfacing an error. + An entry pinned to a question digest other than `q`, or whose accepted + data fails validation against the live `schema`, is dropped as if no + progress was recorded, so the question is asked again. Carries the original decoded entry forward unchanged in `res.persist`: if a later resolver is still pending, the next round's `request_state` is built from diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 36e22f7bf..042ee2d8e 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -241,21 +241,13 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) - # The boundary owns `requestState` at the wire in both directions when - # security is configured; without it, `requestState` passes through - # untouched (the explicitly unprotected posture). It is appended here - - # after the lowlevel server is built and before - # `_install_extension_interceptor` - so it sits inside OpenTelemetry - # (spans record the sealed wire truth) and outside extension - # interceptors (extensions see plaintext). The server name is the - # default audience, so services sharing a key reject each other's - # state unless the policy names its own audience. + # Ordering: inside OpenTelemetry (spans record the sealed wire form), + # outside extension interceptors (extensions see plaintext). if request_state_security is not None: self._lowlevel_server.middleware.append( RequestStateBoundary(request_state_security, default_audience=self.name) ) - # Constructor-supplied Tool objects bypass `add_tool` (ToolManager - # inserts them directly), so gate them here, before any client connects. + # Constructor-supplied Tool objects bypass add_tool, so gate them here. for tool in self._tool_manager.list_tools(): self._check_resolver_protection(tool, owner=f"Tool {tool.name!r}") # Validate auth configuration @@ -577,20 +569,9 @@ async def read_resource( def _check_resolver_protection(self, subject: Tool | Callable[..., Any], *, owner: str) -> None: """Refuse a resolver-tool registration when the server has no request-state security. - Resolver state carries elicited answers — business inputs the SDK - itself authors — so the spec's integrity requirement is not optional - for it. Manual `InputRequiredResult` flows (tools, prompts, resource - templates) are not gated: their state is user-authored, and an - unconfigured server passes it through as plaintext (protection is - recommended, not required). - - Runs before the registration reaches its manager, so a rejected - registration leaves no trace and the server stays usable. A `Tool` is - judged by its stored `resolved_params`; a bare callable (the - `add_tool` funnel) by signature scan, before `Tool.from_function` - runs — for a function that also declares an `InputRequiredResult` - return (a combination `from_function` rejects), this gate fires - first; configuring security then surfaces the signature error. + The spec requires integrity protection for resolver state (SDK-authored + elicited answers). Manual `InputRequiredResult` flows stay ungated: + protection for their user-authored state is only recommended. """ if self._request_state_security is not None: return @@ -628,8 +609,7 @@ def add_tool( - If False, unconditionally creates an unstructured tool Raises: - ValueError: If the tool uses `Resolve(...)` parameters and the - server was constructed without `request_state_security=`. + ValueError: If the tool uses `Resolve(...)` parameters without `request_state_security` configured. """ self._check_resolver_protection(fn, owner=f"Tool {name or fn.__name__!r}") self._tool_manager.add_tool( diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py index aa6e26c58..c003a6a72 100644 --- a/src/mcp/server/request_state.py +++ b/src/mcp/server/request_state.py @@ -1,15 +1,8 @@ """Integrity protection for the multi-round-trip `requestState` (MCP 2026-07-28). -`requestState` round-trips through the client, so the spec requires servers to -treat the echoed value as attacker-controlled, integrity-protect any state that -influences authorization, resource access, or business logic, and reject state -that fails verification (basic/patterns/mrtr, server requirements 4-5). - -This module is the composable tier: `RequestStateBoundary` is a server middleware -that seals every outgoing `requestState` and unseals (verifies) every inbound -echo, so handlers and resolvers only ever see the plaintext state they minted. -`MCPServer` installs it when its `request_state_security=` parameter is -supplied; lowlevel `Server` users append it to `Server.middleware` themselves. +The spec requires servers to treat the client-echoed `requestState` as +attacker-controlled: `RequestStateBoundary` seals every outgoing value and +verifies every inbound echo, so handlers only ever see plaintext they minted. """ from __future__ import annotations @@ -52,37 +45,22 @@ class InvalidRequestState(Exception): """A sealed `requestState` token failed verification. - Raised by `RequestStateCodec.unseal` implementations for any failure — - malformed token, failed authentication, unknown key. The message is a short - reason code for server logs only; the boundary never puts it on the wire. - - (Deliberately not named `InvalidSignature`: that name already exists in - `mcp.server.mcpserver.exceptions` and means a bad Python callable signature.) + The message is a log-only reason code; the boundary never puts it on the wire. """ class RequestStateCodec(Protocol): - """Seals the framework's request-state envelope for its trip through the client. - - Implementations do authenticated crypto over opaque bytes and NOTHING else. - The framework owns the envelope: it stamps mint time, expiry, the originating - request's method/target/argument digest, and the principal tag into the - payload before `seal`, and re-verifies every one of them after `unseal`. A - codec therefore cannot get TTL, replay-binding, or principal-binding wrong — - its only obligations are integrity (tamper -> raise) and, ideally, - confidentiality. - - Requirements: - - `unseal(seal(payload))` round-trips `payload`; `unseal` MUST raise - `InvalidRequestState` for any token it did not mint, or that was - modified in any way. - - The token MUST NOT name its algorithm; version it with a format prefix - bound under the authentication tag (RFC 8725 discipline). - - Comparisons MUST be constant-time (an AEAD primitive satisfies this). - - Prefer an encrypting construction: the payload carries server state and - a salted principal digest; a sign-only codec makes both client-readable. - - Both methods are synchronous; cache key material locally (envelope - encryption) rather than calling a KMS per token — see the docs example. + """Authenticated crypto over the framework's request-state envelope. + + The framework stamps and re-verifies every envelope claim (expiry, request + binding, principal); a codec only provides integrity and, ideally, + confidentiality (a sign-only codec leaves the payload client-readable). + + Requirements: `unseal(seal(payload))` round-trips, and `unseal` raises + `InvalidRequestState` for any token it did not mint unmodified; tokens + never name their algorithm (version with a format prefix bound under the + authentication tag, RFC 8725); comparisons are constant-time. Both methods + are synchronous, so cache key material rather than calling a KMS per token. """ def seal(self, payload: bytes) -> str: @@ -93,54 +71,36 @@ def unseal(self, token: str) -> bytes: """Reverse `seal`. Raises: - InvalidRequestState: If the token is malformed, fails - authentication, or was sealed under an unknown key. + InvalidRequestState: Malformed, unauthentic, or unknown-key token. """ ... def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: - """Default principal binding: the authenticated OAuth client, when present. + """Default principal binding: the authenticated OAuth client's `client_id`. - Reads the access token that `AuthContextMiddleware` stored for this request - and returns its `client_id`. Returns `None` on unauthenticated transports - (stdio, auth-less HTTP), in which case state is not principal-bound. - Replace via `RequestStateSecurity(bind_principal=...)` to bind to a richer - identity (e.g. an end-user subject from your own auth layer). + Returns `None` (state not principal-bound) on unauthenticated transports. """ token = get_access_token() return token.client_id if token is not None else None class RequestStateSecurity: - """Policy for protecting `requestState`: which codec, what TTL, which principal. + """Policy for protecting `requestState`: codec, TTL, principal, audience. Exactly one of `keys` or `codec`: - RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM, shared key(s) + RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto - RequestStateSecurity.ephemeral() # process-local key; single process only - - `keys` is the rotation ring: `keys[0]` seals new state; every key may - unseal. Zero-downtime rotation is three phases (each fully rolled out - before the next): `keys=[old, new]` (every instance learns to verify the - new key; old still mints) -> `keys=[new, old]` (new mints; in-flight old - state keeps verifying) -> after one TTL, `keys=[new]`. - - The sealed envelope carries mint time, a short expiry, the originating - request's method + target + argument digest, and a salted digest of - `bind_principal(ctx)` — the spec's three recommended replay bounds, on by - default and enforced by the boundary for EVERY codec, including custom - ones. Principal binding applies when the SDK authenticates the request (the - default binding derives no principal on unauthenticated transports) and is - fail-closed in both directions: state sealed with a principal is rejected - by a verifier that derives none, and vice versa. - - `audience` distinguishes services that share — or accidentally reuse — a - secret: it is stamped into the envelope and verified fail-closed in both - directions. `None` leaves state audience-unbound unless the server tier - supplies a default (`MCPServer` passes its server name as the boundary's - `default_audience`). + RequestStateSecurity.ephemeral() # process-local key + + `keys` is the rotation ring: `keys[0]` seals, every key unseals. + Zero-downtime rotation, each phase fully rolled out before the next: + `keys=[old, new]`, then `keys=[new, old]`, then `keys=[new]` after one TTL. + + The boundary enforces expiry, request binding, audience, and principal for + every codec, fail-closed in both directions. `audience=None` defers to the + boundary's `default_audience` (`MCPServer` passes its server name). """ codec: RequestStateCodec @@ -164,7 +124,7 @@ def __init__( if keys is not None: self.codec = AESGCMRequestStateCodec(keys) else: - assert codec is not None # the exactly-one-of check above + assert codec is not None self.codec = codec self.ttl = ttl self.bind_principal = bind_principal @@ -174,14 +134,9 @@ def __init__( def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity: """Protection under a key generated now and held only by this process. - Valid for single-process deployments (stdio, a single HTTP worker): the - one process that mints state is the one that receives the retry. It - FAILS across instances and restarts — state minted before a restart, or - by another worker behind a load balancer, is rejected with the standard - "Invalid or expired requestState" error and the client must start the - flow over. Multi-instance deployments must share a key: - `RequestStateSecurity(keys=[...])`. `ttl` and `audience` carry the same - meaning as on the main constructor. + Suits single-process deployments (stdio, one HTTP worker): state minted + before a restart or by another worker is rejected. Multi-instance + deployments must share a key via `keys=[...]`. """ return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) @@ -198,12 +153,7 @@ def _b64u(data: bytes) -> str: def _b64u_decode(text: str) -> bytes: - """Strict inverse of `_b64u`: only the canonical unpadded encoding decodes. - - The round-trip check rejects every malleable variant a lax decoder admits — - non-zero trailing don't-care bits, injected non-alphabet characters, and - appended padding — raising ValueError for all of them. - """ + """Strict inverse of `_b64u`: only the canonical unpadded encoding decodes.""" raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) if _b64u(raw) != text: raise ValueError("non-canonical base64url") @@ -218,21 +168,12 @@ def _derive_key(secret: bytes) -> bytes: class AESGCMRequestStateCodec: """Built-in codec: AES-256-GCM under key(s) derived with HKDF-SHA256. - The token is opaque: contents are encrypted, not merely signed, so clients - (and anything that logs the wire) cannot read resolver keys, elicited - answers, or whatever a manual flow put in its state. `keys[0]` seals; all - keys unseal (rotation, see `RequestStateSecurity`). Each token carries a - 4-byte non-secret fingerprint of its key, so verification is an O(1) ring - lookup — never trial decryption. Key bytes are copied at construction, so - later mutation of a caller-held bytearray has no effect. - - The "v1." prefix and the key fingerprint are fed into the GCM associated - data, so both are bound under the authentication tag: a v1 token cannot be - replayed into a future "v2." format, nor transplanted across ring slots - (RFC 8725 discipline — the token never names an algorithm; the version - prefix pins the whole construction server-side). Authentication failure is - constant-time inside the AEAD primitive, and every failure raises - `InvalidRequestState` with a log-only reason code. + Tokens are encrypted, not merely signed, so clients cannot read the state. + `keys[0]` seals; all keys unseal (rotation, see `RequestStateSecurity`). + Each token carries a 4-byte non-secret key fingerprint for an O(1) ring + lookup, and the "v1." prefix and fingerprint are bound into the GCM + associated data, so a token cannot be replayed into another format version + or ring slot. Key bytes are copied at construction. """ def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: @@ -288,15 +229,14 @@ def unseal(self, token: str) -> bytes: raise InvalidRequestState("seal") from None -# The multi-round-trip carriers — the only methods whose results may carry -# `requestState`. Single source: the monolith result map in `mcp_types.methods`. +# The multi-round-trip carriers: the only methods whose results may carry `requestState`. _MRTR_METHODS = INPUT_REQUIRED_METHODS _ENVELOPE_VERSION = 1 _FUTURE_SKEW = 60.0 _PRINCIPAL_LABEL = b"mcp/request-state/principal:" _RoundBinding = tuple[str, str, str | None] -"""(target, args-digest, principal) one round's envelope binds — computed once per round.""" +"""The (target, args-digest, principal) one round's envelope binds, computed once per round.""" def _reject(method: str, reason: str) -> NoReturn: @@ -312,10 +252,7 @@ def _reject(method: str, reason: str) -> NoReturn: def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[str, str]: """Salient (target, args-digest) for the request a token binds to. - Explicit per-method allowlist (never a denylist): tools/call and - prompts/get bind name + arguments; resources/read binds the uri. Retry-only - fields (inputResponses, requestState, _meta) are structurally excluded, and - a future wire field cannot silently join the digest. + Per-method allowlist, never a denylist: a future wire field cannot silently join the digest. """ p: Mapping[str, Any] = params or {} args: dict[str, Any] = {} @@ -338,8 +275,7 @@ def _principal_matches(claim: str, principal: str) -> bool: raw = _b64u_decode(claim) except ValueError: return False - # A wrong-length claim cannot match: the recomputed 16-byte tag never - # equals a differently-sized remainder (compare_digest handles the sizes). + # A wrong-length claim never matches: compare_digest handles mismatched sizes. expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + principal.encode()).digest()[:16] return hmac.compare_digest(raw[8:], expected) @@ -347,44 +283,20 @@ def _principal_matches(claim: str, principal: str) -> bool: class RequestStateBoundary: """Server middleware sealing/unsealing `requestState` at the wire boundary. - The boundary acts only on the multi-round-trip carriers (tools/call, - prompts/get, resources/read) — the only methods whose results may carry - `requestState`. Every other method passes through untouched: a - "requestState" member appearing in some other method's params is outside - the multi-round-trip protocol, is never verified, and must never be - trusted by whatever handles it. - - Inbound: a carrier request presenting `requestState` (any non-null value) - is handled before any extension interceptor or handler runs: the value is - verified (codec unseal + claims check: version, mint-time skew, expiry, - method, target, argument digest, audience, principal) and replaced with - the plaintext the server originally minted. Verification failure answers a - wire-level -32602 with the frozen message "Invalid or expired - requestState"; the underlying reason goes to the server log only. - - Outbound: an `input_required` result carrying `requestState` has it sealed - inside a fresh claims envelope. Handlers and resolvers write plaintext and - never call the codec themselves. - - `default_audience` seeds the envelope's audience claim when the policy does - not set its own `audience`. `MCPServer` passes its server name, so two - services sharing (or accidentally reusing) a key reject each other's state - by default. - - `ctx.params` is the raw, unvalidated wire mapping (no model validation has - happened yet), so the field is the camelCase wire key "requestState"; the - inbound rewrite replaces that key on a copy of the params and forwards it - with `dataclasses.replace(ctx, params=...)` — the rewrite contract - `ServerMiddleware` sanctions. - - `MCPServer` installs this only when `request_state_security=` is supplied; - without it, `requestState` passes through untouched — the explicitly - unprotected posture, which the spec permits only when tampering can cause - nothing worse than a failed request. Protection is required only for - resolver tools (`Resolve(...)` parameters — state the SDK itself authors) - and recommended for everything else. Lowlevel `Server` users append one to - `server.middleware` — they get the identical claims enforcement; nothing - is private to MCPServer. + Acts only on the multi-round-trip carriers (tools/call, prompts/get, + resources/read); every other method passes through untouched. + + Inbound state is verified (codec unseal plus claims check) and replaced + with the plaintext the server minted before any interceptor or handler + runs; failure answers -32602 with the frozen message "Invalid or expired + requestState", the real reason going to the server log only. Outbound, an + `input_required` result carrying `requestState` is sealed in a fresh + claims envelope; handlers and resolvers never call the codec. + + `default_audience` seeds the audience claim when the policy sets none + (`MCPServer` passes its server name). `MCPServer` installs this when + `request_state_security=` is supplied; lowlevel `Server` users append one + to `server.middleware` for identical enforcement. """ def __init__(self, security: RequestStateSecurity, *, default_audience: str | None = None) -> None: @@ -396,16 +308,12 @@ async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNex return await call_next(ctx) binding: _RoundBinding | None = None if ctx.params is not None and ctx.params.get("requestState") is not None: - # An explicit JSON null is the field's absence (a fresh flow): only - # presented state is verified, and stripping the field is already - # in any client's power. + # An explicit JSON null counts as absent: stripping the field is already in any client's power. plaintext, binding = self._unseal(ctx) ctx = replace(ctx, params={**ctx.params, "requestState": plaintext}) result = await call_next(ctx) return self._seal_result(ctx, result, binding) - # -- inbound ------------------------------------------------------------ - def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding]: assert ctx.params is not None wire = ctx.params["requestState"] @@ -427,9 +335,7 @@ def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBindi if version != _ENVELOPE_VERSION or not isinstance(inner, str): _reject(ctx.method, "malformed") now = time.time() - # Accept-conditions are stated positively so a claim that defeats - # comparison (a NaN smuggled through a weak custom codec) reads as - # unproven and rejects. + # Accept-conditions are stated positively so a NaN claim fails the comparison and rejects. if not isinstance(iat, int | float) or not (iat <= now + _FUTURE_SKEW): _reject(ctx.method, "minted in the future") if not isinstance(exp, int | float) or not (now < exp): @@ -438,7 +344,7 @@ def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBindi if claims.get("m") != ctx.method or claims.get("t") != target or claims.get("a") != args_digest: _reject(ctx.method, "request binding") if claims.get("aud") != self._audience: - _reject(ctx.method, "audience") # fail closed in BOTH directions + _reject(ctx.method, "audience") try: principal = security.bind_principal(ctx) if security.bind_principal is not None else None except Exception: # deny-on-error: a raising principal binding must fail closed @@ -446,20 +352,16 @@ def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBindi _reject(ctx.method, "principal binding error") claim = claims.get("p") if (claim is None) != (principal is None): - _reject(ctx.method, "principal drift") # fail closed in BOTH directions + _reject(ctx.method, "principal drift") if claim is not None and principal is not None: if not isinstance(claim, str) or not _principal_matches(claim, principal): _reject(ctx.method, "principal") return inner, (target, args_digest, principal) - # -- outbound ----------------------------------------------------------- - def _seal_result( self, ctx: ServerRequestContext[Any, Any], result: HandlerResult, binding: _RoundBinding | None ) -> HandlerResult: - # Results arrive as wire mappings on the spec path (serialization runs - # inside the chain); a middleware short-circuiting below the boundary - # may return a model. Both shapes are sealed. + # Spec-path results arrive as wire mappings; a short-circuiting middleware may return a model. if not is_input_required(result): return result state = result.get("requestState") if isinstance(result, Mapping) else result.request_state @@ -467,9 +369,7 @@ def _seal_result( return result if isinstance(result, Mapping): if not isinstance(state, str): - # Only a short-circuiting middleware can put a non-string here - # (the spec path validated the field as a string); there is no - # state for this module to seal. + # Only a short-circuiting middleware can put a non-string here; nothing to seal. return result return {**result, "requestState": self._seal(ctx, state, binding)} return result.model_copy(update={"request_state": self._seal(ctx, state, binding)}) diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 93434017c..1a47302ad 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -174,11 +174,10 @@ def test_a_custom_codec_round_trips_what_it_sealed() -> None: def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None: - """tutorial005: a modified token and a token it never minted both raise `InvalidRequestState` - - the codec's whole contract. TTL, principal, and request binding are the SDK's job, not the codec's.""" + """tutorial005: any token the codec did not mint intact raises `InvalidRequestState`.""" codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) token = codec.seal(b"round-1") with pytest.raises(InvalidRequestState): - codec.unseal(token + "00") # extra ciphertext bytes: authentication fails + codec.unseal(token + "00") with pytest.raises(InvalidRequestState): codec.unseal("not-a-token") diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index b8f6f79d9..c414dd1c9 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -131,11 +131,8 @@ def _answer_round( return responses -# A fixed key so tests can unseal the wire `request_state` a server minted (and -# seal crafted state a server will accept): the boundary's claims envelope -# carries the inner plaintext state as the "s" claim. Servers under test whose -# wire state a test reads or writes are constructed with -# `RequestStateSecurity(keys=[_PIN_KEY])`. +# Fixed key shared with servers under test, so tests can unseal minted wire +# state and seal crafted state the server will accept. _PIN_KEY = b"0123456789abcdef0123456789abcdef" @@ -156,12 +153,9 @@ def _outcomes_on_the_wire(request_state: str | None) -> dict[str, Any]: def _sealed_state(inner: str, *, tool: str, args: dict[str, Any], audience: str) -> str: """Seal a hand-built inner state exactly as the boundary does for a `tools/call` retry. - Goes through the production `RequestStateBoundary._seal` so the claims - envelope cannot drift from what the server-side unseal verifies. The claims - bind method + tool + arguments + audience (and no principal: the in-memory - transport is unauthenticated, so both seal and unseal derive None), so the - test must then call exactly `tool` with exactly `args` on the MCPServer - named `audience` (the server name is the boundary's default audience). + The production `RequestStateBoundary._seal` binds method, tool, arguments, and + audience (the server name), so the test must then call exactly `tool` with + exactly `args` on the MCPServer named `audience`. """ ctx = ServerRequestContext( session=cast("Any", None), @@ -1376,8 +1370,7 @@ async def act( @pytest.mark.anyio async def test_unknown_response_keys_and_ghost_state_entries_are_ignored(): # `input_responses` keys the server never asked for and `request_state` outcome - # entries matching no resolver are tolerated, and the ghost state entry is not - # echoed into any later round's `request_state`. + # entries matching no resolver are tolerated and not echoed into later rounds. mcp = MCPServer(name="GhostKeys", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ra(ctx: Context) -> Elicit[Login]: @@ -1401,8 +1394,7 @@ async def act( (ra_key,) = first.input_requests spliced = json.loads(_unseal_inner(first.request_state)) - # A well-formed v2 entry (question digest included) under a key matching - # no resolver: it must be dropped for being unknown, not as malformed. + # A well-formed v2 entry under an unknown key: dropped as unknown, not as malformed. spliced["outcomes"]["ghost"] = { "action": "accept", "data": {"username": "spooky"}, @@ -1446,10 +1438,7 @@ async def act( ], ) async def test_forged_state_entry_failing_the_schema_is_reasked_not_an_error(forged_data: str | dict[str, bool]): - # Even boundary-authenticated state is not schema-trusted: an accept entry - # whose data does not validate against the resolver's schema reads as no - # recorded progress, so the question is asked again (not an error) and a - # proper answer completes the call. + # Authenticated state is not schema-trusted: a failing accept entry reads as no progress and is re-asked. mcp = MCPServer(name="ForgedState", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ask(ctx: Context) -> Elicit[Login]: @@ -1467,8 +1456,7 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: (key,) = first.input_requests forged = json.loads(_unseal_inner(first.request_state)) - # The digest matches the live question, so the forged entry survives the - # question-pinning gate and stands or falls on schema validation alone. + # The digest matches the live question, so the entry stands or falls on schema alone. forged["outcomes"][key] = { "action": "accept", "data": forged_data, @@ -1699,10 +1687,7 @@ async def decide(ctx: Context) -> Restock | Elicit[Restock]: async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: return str(restock.needed) - # A decodable v2 entry; the digest's value cannot matter because the - # resolver computes without asking, so there is no live question to pin to. - # The property is that the entry is never consulted, not that it is dropped - # as malformed. + # A decodable v2 entry; the resolver never asks, so it must go unconsulted, not dropped as malformed. entry = {"action": "accept", "data": {"needed": True}, "q": _question_digest(Elicit("Restock?", Restock))} crafted = json.dumps({"v": 2, "outcomes": {_wire_key(decide): entry}}) @@ -1733,8 +1718,7 @@ async def lookup(ctx: Context) -> Login: async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str: return login.username - # A decodable v2 entry with a plausible digest: `lookup` has no Elicit arm, - # so no value of `q` could make this decline answer a question it asks. + # A decodable v2 entry: `lookup` never asks, so no digest can make the decline apply. entry = {"action": "decline", "q": _question_digest(Elicit("user?", Login))} crafted = json.dumps({"v": 2, "outcomes": {_wire_key(lookup): entry}}) @@ -1878,8 +1862,7 @@ async def sneaky(login: Annotated[Login, Resolve(lookup)]): def test_question_digest_pins_the_rendered_question(): - # The digest is computed over the rendered wire question, so it is stable for - # an identical Elicit and changes when the message or the schema changes. + # Computed over the rendered wire question: identical Elicits agree, any change diverges. digest = _question_digest(Elicit("Name?", Login)) assert digest == _question_digest(Elicit("Name?", Login)) assert digest != _question_digest(Elicit("Your name, please?", Login)) @@ -1889,9 +1872,7 @@ def test_question_digest_pins_the_rendered_question(): def test_state_round_trips_question_digests_at_v2(): - # v2 entries carry the question digest for every action; encode-decode is the - # identity on them. A v1 payload (a not-yet-upgraded fleet member during a - # rolling deploy) decodes to "no progress yet" - a graceful re-ask, not an error. + # v2 carries digests for every action and round-trips exactly; v1 (mid rolling deploy) reads as no progress. entries = { "a": _StateEntry(action="accept", data={"username": "octocat"}, q="qa"), "b": _StateEntry(action="decline", q="qb"), @@ -1906,9 +1887,6 @@ def test_state_round_trips_question_digests_at_v2(): @pytest.mark.anyio async def test_restored_answer_with_matching_digest_completes_without_reasking(): - # The happy path under pinning: a stored accept answer whose question is - # unchanged restores on a later round and the flow completes without the - # question being asked a second time. mcp = MCPServer(name="PinHappyPath", request_state_security=RequestStateSecurity.ephemeral()) async def who(ctx: Context) -> Elicit[Login]: @@ -1956,9 +1934,7 @@ async def act( @pytest.mark.anyio async def test_restored_entry_is_repersisted_with_its_question_digest_intact(): - # An entry restored into a round that still pends is carried into that round's - # `request_state` unchanged - including its question digest - or the answer - # would be dropped and re-asked on the round after. + # A restored entry must ride into the next round's state digest-intact, or it would be re-asked next round. mcp = MCPServer(name="RepersistPin", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def who(ctx: Context) -> Elicit[Login]: @@ -1970,8 +1946,7 @@ async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]: async def plan(confirm: Annotated[Confirm, Resolve(check)], ctx: Context) -> Elicit[Restock]: return Elicit("Restock too?", Restock) - # The test stops while a question pends, so the body never runs: a bare `...` - # is a constant statement the compiler eliminates - nothing for coverage to miss. + # The body never runs (a question always pends); a bare `...` costs no coverage. @mcp.tool() async def act(restock: Annotated[Restock, Resolve(plan)]) -> str: ... @@ -2000,7 +1975,6 @@ async def act(restock: Annotated[Restock, Resolve(plan)]) -> str: ... # Accept entries are pinned to the exact rendered question they answered. assert round_two[_wire_key(who)]["q"] == _question_digest(Elicit("Who?", Login)) assert round_three[_wire_key(check)]["q"] == _question_digest(Elicit("Go as octocat?", Confirm)) - # The restored entry rides into round 3's state exactly as round 2 stored it. assert round_three[_wire_key(who)] == round_two[_wire_key(who)] @@ -2017,8 +1991,7 @@ async def ask_confirm(ctx: Context) -> Elicit[Confirm]: async def ask_restock(ctx: Context) -> Elicit[Restock]: return Elicit("Restock?", Restock) - # The test stops while a question pends, so the body never runs: a bare `...` - # is a constant statement the compiler eliminates - nothing for coverage to miss. + # The body never runs (a question always pends); a bare `...` costs no coverage. @mcp.tool() async def act( name: Annotated[ElicitationResult[Login], Resolve(ask_name)], @@ -2029,8 +2002,7 @@ async def act( async with Client(mcp, elicitation_callback=_never) as client: first = await client.session.call_tool("act", {}, allow_input_required=True) assert isinstance(first, InputRequiredResult) - # Decline one question and cancel another; the third stays unanswered so the - # call pends and the recorded outcomes are observable on the wire. + # The third question stays unanswered, so the call pends and outcomes hit the wire. second = await client.session.call_tool( "act", {}, @@ -2052,10 +2024,7 @@ async def act( @pytest.mark.anyio async def test_state_entry_without_a_question_digest_is_dropped_and_reasked(): - # v2 semantics: an entry whose `q` is None (absent digest) cannot prove which - # rendered question it answered, so it reads as no recorded progress - the live - # question is asked again rather than honoring an unpinned answer - and a proper - # answer then completes the call. + # An entry with no digest cannot prove its question, so it reads as no progress and is re-asked. mcp = MCPServer(name="UnpinnedEntry", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ask(ctx: Context) -> Elicit[Login]: @@ -2099,9 +2068,7 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: @pytest.mark.anyio async def test_reworded_question_drops_the_stored_answer_and_reasks(): - # A stored accept answer is honored only while the question is byte-identical: - # rewording it between rounds (a redeploy) drops the entry and re-asks - a soft - # self-heal, never an error - while other entries in the same state survive. + # An answer holds only while its question is byte-identical: a reword (redeploy) drops it and re-asks. mcp = MCPServer(name="RewordAccept", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) wording = {"deploy": "Deploy to prod?"} @@ -2133,7 +2100,6 @@ async def act( Elicit("Deploy to prod?", Confirm) ) - # The server rewords the question between rounds (a redeploy). wording["deploy"] = "Deploy to staging?" third = await client.session.call_tool( @@ -2143,7 +2109,7 @@ async def act( request_state=second.request_state, allow_input_required=True, ) - # The stale answer is dropped and the reworded question is asked - not an error. + # The stale answer is dropped and the reworded question is asked, not an error. assert isinstance(third, InputRequiredResult) assert third.input_requests is not None assert set(third.input_requests) == {_wire_key(ask_deploy)} @@ -2169,8 +2135,7 @@ async def act( @pytest.mark.anyio async def test_decline_of_a_reworded_question_does_not_suppress_the_new_question(): - # Decline entries are pinned too: a decline recorded for question A must not - # suppress re-asking once the question is reworded into question B. + # A decline pinned to the old wording must not suppress the reworded question. mcp = MCPServer(name="RewordDecline", request_state_security=RequestStateSecurity.ephemeral()) wording = {"q": "Use defaults?"} diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index ecae00fdb..8fb36267c 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1867,8 +1867,7 @@ def get_user(user_id: str) -> str: async def test_tool_returning_input_required_result_reaches_client_unchanged(): - # Unconfigured server: the wire carries the handler's requestState exactly as - # written, the plaintext posture a declared-manual surface gets by default. + # Unconfigured server: the wire carries the handler's requestState as plaintext. mcp = MCPServer() @mcp.tool() @@ -1944,10 +1943,7 @@ def _ask_who() -> ElicitRequest: async def test_prompt_returning_input_required_result_reaches_client_unchanged(): """A prompt function may return an InputRequiredResult and the pipeline passes it - through to the client (spec-mandated: SEP-2322 allows it on prompts/get). - - Unconfigured server: the assertion is on the verbatim wire requestState, the - plaintext posture a declared-manual surface gets by default.""" + through to the client (spec-mandated: SEP-2322 allows it on prompts/get).""" mcp = MCPServer() @mcp.prompt() @@ -2030,10 +2026,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_resource_template_returning_input_required_result_reaches_client_unchanged(): """A resource template function may return an InputRequiredResult and the pipeline - passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read). - - Unconfigured server: the assertion is on the verbatim wire requestState, the - plaintext posture a declared-manual surface gets by default.""" + passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read).""" mcp = MCPServer() @mcp.resource("ask://{topic}") @@ -2119,9 +2112,8 @@ async def test_context_read_resource_keeps_outer_input_responses_from_the_nested template must not see the outer request's input_responses/request_state — a colliding key would otherwise consume an answer meant for the outer handler's own question. - Unconfigured server: the probe below is client-built plaintext state that must - reach the outer request's context as-sent - the subject is nested-context - isolation, not the wire seal (no surface here mints state at all).""" + Unconfigured server: the client-built plaintext probe must reach the outer + context as-sent; the subject is isolation, not the wire seal.""" mcp = MCPServer() seen_responses: list[InputResponses | None] = [] seen_state: list[str | None] = [] diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py index ee4cd0e44..621602c9a 100644 --- a/tests/server/test_request_state.py +++ b/tests/server/test_request_state.py @@ -1,7 +1,4 @@ -"""`mcp.server.request_state` unit tier: the `AESGCMRequestStateCodec` token -format, tamper rejection, and key-ring rotation, plus the `RequestStateSecurity` -policy object and the `authenticated_principal` default binding. Wire-level -boundary behavior lives in its own phase; nothing here crosses a transport.""" +"""Unit tests for `mcp.server.request_state`: codec, security policy, and default principal binding.""" import base64 import string @@ -36,8 +33,7 @@ # Distinctive plaintext: opacity and log-secrecy assertions search for it. _PAYLOAD = b"sentinel-plaintext-3f9c" -# `InvalidRequestState` messages are log-only reason codes ("malformed", -# "unknown key", ...), never prose and never payload material. +# `InvalidRequestState` messages are short log-only reason codes, never payload. _REASON_CODE_MAX_LEN = 40 @@ -121,15 +117,13 @@ def unseal(self, token: str) -> bytes: ], ) def test_seal_unseal_round_trips_any_payload(payload: bytes) -> None: - """SDK-defined: the codec is byte-transparent — empty, text, raw-binary, and - large payloads all survive seal/unseal unchanged.""" + """SDK-defined: the codec is byte-transparent, so any payload survives seal/unseal unchanged.""" codec = AESGCMRequestStateCodec([_KEY_A]) assert codec.unseal(codec.seal(payload)) == payload def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext() -> None: - """SDK-defined token format: "v1." then unpadded base64url whose decoded body - is kid(4) || nonce(12) || GCM ciphertext+tag (payload length + 16).""" + """SDK-defined token format: "v1." plus unpadded base64url over kid(4) || nonce(12) || ciphertext+tag.""" token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) assert token.startswith(_TOKEN_PREFIX) body = token.removeprefix(_TOKEN_PREFIX) @@ -139,8 +133,7 @@ def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal() -> None: - """SDK-defined: every seal draws a fresh nonce, so identical payloads yield - distinct tokens — and each one independently verifies.""" + """SDK-defined: every seal draws a fresh nonce, so identical payloads yield distinct tokens that both verify.""" codec = AESGCMRequestStateCodec([_KEY_A]) first = codec.seal(_PAYLOAD) second = codec.seal(_PAYLOAD) @@ -162,9 +155,7 @@ def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal( def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload( corrupt: Callable[[str], str], ) -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): state that fails - verification is rejected — flipping any region of the token (prefix, kid, - nonce, ciphertext, tag) raises, with only a short reason code in the message.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): any corrupted token region is rejected.""" codec = AESGCMRequestStateCodec([_KEY_A]) token = codec.seal(_PAYLOAD) with pytest.raises(InvalidRequestState) as exc: @@ -184,17 +175,13 @@ def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload ], ) def test_a_structurally_malformed_token_is_rejected(token: str) -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec - could never have minted — empty, unprefixed, undecodable, or shorter than the - kid+nonce+tag floor — fail verification.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec never minted fail.""" with pytest.raises(InvalidRequestState): AESGCMRequestStateCodec([_KEY_A]).unseal(token) def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key() -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): a token sealed - under a key this ring never held fails its O(1) kid lookup, with the log-only - reason "unknown key".""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): a foreign-key token fails as "unknown key".""" token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) with pytest.raises(InvalidRequestState) as exc: AESGCMRequestStateCodec([_KEY_B]).unseal(token) @@ -209,15 +196,13 @@ def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key( ], ) def test_a_token_minted_under_the_old_key_unseals_under_any_ring_containing_it(ring: list[bytes]) -> None: - """SDK-defined rotation: every ring key verifies, so in-flight old-key state - survives both the [old, new] and the [new, old] rollout phases.""" + """SDK-defined rotation: every ring key verifies, so old-key state survives both rollout phases.""" token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) assert AESGCMRequestStateCodec(ring).unseal(token) == _PAYLOAD def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None: - """SDK-defined rotation: keys[0] is the minter — phase-2 [new, old] state - verifies under a [new]-only ring and fails under an [old]-only ring.""" + """SDK-defined rotation: keys[0] is the minter, so [new, old] state verifies under [new] but not [old].""" token = AESGCMRequestStateCodec([_KEY_NEW, _KEY_OLD]).seal(_PAYLOAD) assert AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) == _PAYLOAD with pytest.raises(InvalidRequestState): @@ -225,25 +210,21 @@ def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None: def test_a_token_minted_under_a_retired_key_is_rejected() -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): once rotation - completes ([old] -> [new]), state minted under the retired key fails - verification and the client must restart the flow.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): retired-key state fails verification.""" token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) with pytest.raises(InvalidRequestState): AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) def test_an_empty_key_ring_is_rejected_at_construction() -> None: - """SDK-defined: a codec with nothing to mint under is a configuration error, - caught at construction rather than on the first seal.""" + """SDK-defined: an empty ring is a configuration error caught at construction.""" with pytest.raises(ValueError) as exc: AESGCMRequestStateCodec([]) assert str(exc.value) == snapshot("AESGCMRequestStateCodec requires at least one key") def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> None: - """SDK-defined: keys carry at least 32 bytes of secret material; the - construction error tells the operator how to generate one.""" + """SDK-defined: keys must carry at least 32 bytes; the error includes generation guidance.""" with pytest.raises(ValueError) as exc: AESGCMRequestStateCodec([b"k" * 31]) assert str(exc.value) == snapshot( @@ -253,17 +234,14 @@ def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> N def test_a_duplicate_key_in_the_ring_is_rejected_at_construction() -> None: - """SDK-defined: two ring slots holding the same key is a rotation mistake - (the duplicate could never be retired independently), caught at construction.""" + """SDK-defined: duplicate ring keys are a rotation mistake caught at construction.""" with pytest.raises(ValueError) as exc: AESGCMRequestStateCodec([_KEY_A, _KEY_A]) assert str(exc.value) == snapshot("keys[1] duplicates an earlier ring key") def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> None: - """SDK-defined: a ring entry that is not bytes/bytearray/str is a TypeError at - construction — never coerced (bytes(32) would silently build an all-zero key) — - naming the offending index and type, through the codec and the policy alike.""" + """SDK-defined: a non-key ring entry raises a TypeError naming its index and type, in codec and policy.""" with pytest.raises(TypeError) as exc: AESGCMRequestStateCodec([_KEY_A, cast("Any", 32)]) assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[1] is int") @@ -273,8 +251,7 @@ def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> N def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None: - """SDK-defined: the three documented key spellings interoperate in one ring, and - each entry can mint a token the mixed ring verifies.""" + """SDK-defined: bytes, bytearray, and str keys interoperate in one ring.""" codec = AESGCMRequestStateCodec([_KEY_A, bytearray(_KEY_B), "c" * 32]) assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD assert codec.unseal(AESGCMRequestStateCodec([bytearray(_KEY_B)]).seal(_PAYLOAD)) == _PAYLOAD @@ -282,16 +259,13 @@ def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None: def test_a_str_key_is_equivalent_to_its_utf8_bytes_form() -> None: - """SDK-defined: str keys are utf-8 encoded before derivation, so "k"*32 and - b"k"*32 are the same ring key — tokens cross between the two spellings.""" + """SDK-defined: a str key is utf-8 encoded, so it is the same ring key as its bytes spelling.""" token = AESGCMRequestStateCodec(["k" * 32]).seal(_PAYLOAD) assert AESGCMRequestStateCodec([b"k" * 32]).unseal(token) == _PAYLOAD def test_bytearray_key_material_is_copied_at_construction() -> None: - """SDK-defined: key bytes are copied at construction, so mutating the - caller-held bytearray afterwards changes neither verification of existing - tokens nor the key new tokens are minted under.""" + """SDK-defined: key bytes are copied at construction; mutating the caller's bytearray later has no effect.""" material = bytearray(b"m" * 32) codec = AESGCMRequestStateCodec([cast("Any", material)]) minted_before_mutation = codec.seal(_PAYLOAD) @@ -301,9 +275,7 @@ def test_bytearray_key_material_is_copied_at_construction() -> None: def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes() -> None: - """SDK-defined: the token is encrypted, not merely signed — the plaintext - appears in neither the token string (directly, base64url'd, or hex'd) nor - the decoded token body.""" + """SDK-defined: the token is encrypted, not merely signed, so the plaintext appears nowhere in it.""" token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) assert _PAYLOAD.decode() not in token assert _b64u_nopad(_PAYLOAD) not in token @@ -312,10 +284,8 @@ def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes def test_every_substitution_of_the_final_token_character_is_rejected() -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4) via strict canonical - decoding: the final base64url character's don't-care padding bits no longer make - variants decode identically — all 63 single-character substitutions at the last - position fail verification.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): canonical decoding + rejects every final-character substitution despite base64 don't-care padding bits.""" codec = AESGCMRequestStateCodec([_KEY_A]) body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) substitutions = [c for c in sorted(_B64URL_ALPHABET) if c != body[-1]] @@ -334,10 +304,7 @@ def test_every_substitution_of_the_final_token_character_is_rejected() -> None: ], ) def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4) via strict canonical - decoding: a lax decoder discards non-alphabet characters and tolerates appended - padding, so infinitely many strings would alias one minted token; only the exact - canonical encoding verifies.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): lax-decoder aliases of a token are rejected.""" codec = AESGCMRequestStateCodec([_KEY_A]) body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) with pytest.raises(InvalidRequestState): @@ -345,9 +312,7 @@ def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): the format - prefix is bound under the authentication tag (RFC 8725 discipline), so a v1 - token cannot be replayed as "v2.".""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the prefix is tag-bound; "v2." replay fails.""" codec = AESGCMRequestStateCodec([_KEY_A]) token = codec.seal(_PAYLOAD) with pytest.raises(InvalidRequestState): @@ -355,10 +320,7 @@ def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None: def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None: - """Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is bound - under the authentication tag, so grafting one valid token's key fingerprint - onto another valid token's nonce+ciphertext fails verification even when the - verifier's ring knows both keys.""" + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is tag-bound; transplanting it fails.""" raw_a = _decode_body(AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)) raw_b = _decode_body(AESGCMRequestStateCodec([_KEY_B]).seal(_PAYLOAD)) assert raw_a[:_KID_LEN] != raw_b[:_KID_LEN] @@ -371,17 +333,14 @@ def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None: def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: - """SDK-defined: keys= and codec= are mutually exclusive spellings of the same - decision; passing both is ambiguous and fails immediately.""" + """SDK-defined: keys= and codec= are mutually exclusive.""" with pytest.raises(ValueError) as exc: RequestStateSecurity(keys=[_KEY_A], codec=_StaticCodec()) assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: - """SDK-defined: there is no implicit default protection — a policy must name - its codec, so the bare constructor fails. (Going without protection is spelled - by not configuring `request_state_security=` at all, not by an empty policy.)""" + """SDK-defined: a policy must name its codec; opting out means omitting `request_state_security=` entirely.""" with pytest.raises(ValueError) as exc: RequestStateSecurity() assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") @@ -397,10 +356,7 @@ def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: ], ) def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl: float) -> None: - """SDK-defined: ttl bounds per-round client think time, so zero and negative values - (state that could never verify), NaN (every expiry comparison would read as - unexpired), and infinity (state that never expires) all fail at construction — for - explicit keys and for ephemeral() alike.""" + """SDK-defined: zero, negative, NaN, and infinite ttl fail at construction for keys and ephemeral() alike.""" with pytest.raises(ValueError, match="positive finite"): RequestStateSecurity(keys=[_KEY_A], ttl=ttl) with pytest.raises(ValueError, match="positive finite"): @@ -408,16 +364,14 @@ def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl def test_keys_produce_a_working_built_in_codec_on_the_policy() -> None: - """SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on - .codec and immediately able to round-trip.""" + """SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on .codec.""" security = RequestStateSecurity(keys=[_KEY_A]) assert isinstance(security.codec, AESGCMRequestStateCodec) assert security.codec.unseal(security.codec.seal(_PAYLOAD)) == _PAYLOAD def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None: - """SDK-defined: codec=... stores the caller's object identically — no - wrapping, so calls through .codec hit the custom implementation directly.""" + """SDK-defined: codec=... stores the caller's object unwrapped.""" codec = _StaticCodec() security = RequestStateSecurity(codec=codec) assert security.codec is codec @@ -425,9 +379,7 @@ def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None: def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None: - """SDK-defined: ephemeral() is real protection under a key held only by its - own process — so a sibling ephemeral() instance rejects its tokens, the - documented single-process limitation.""" + """SDK-defined: ephemeral() protects under a process-local key, so a sibling instance rejects its tokens.""" first = RequestStateSecurity.ephemeral() second = RequestStateSecurity.ephemeral() token = first.codec.seal(_PAYLOAD) @@ -437,22 +389,19 @@ def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None: def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None: - """SDK-defined: `audience` is stored as given for the boundary to stamp and verify; - the default None leaves the decision to the server tier's `default_audience`.""" + """SDK-defined: audience is stored as given; None defers to the server tier's `default_audience`.""" assert RequestStateSecurity(keys=[_KEY_A]).audience is None assert RequestStateSecurity(keys=[_KEY_A], audience="svc").audience == "svc" assert RequestStateSecurity.ephemeral(audience="svc").audience == "svc" def test_the_default_principal_binding_is_authenticated_principal() -> None: - """SDK-defined: principal binding is on by default — an unconfigured policy - binds state to the authenticated OAuth client.""" + """SDK-defined: an unconfigured policy binds state to the authenticated OAuth client by default.""" assert RequestStateSecurity(keys=[_KEY_A]).bind_principal is authenticated_principal def test_an_explicit_principal_binding_callable_is_stored() -> None: - """SDK-defined: a custom bind_principal callable is stored as given, so the - boundary later invokes exactly the operator's identity function.""" + """SDK-defined: a custom bind_principal callable is stored as given.""" def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None: return "tenant-1" @@ -466,14 +415,12 @@ def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None: def test_authenticated_principal_is_none_without_an_auth_context() -> None: - """SDK-defined: on unauthenticated transports there is no access token in - context, so the default binding derives no principal.""" + """SDK-defined: without an auth context the default binding derives no principal.""" assert authenticated_principal(_bare_context()) is None def test_authenticated_principal_returns_the_access_tokens_client_id() -> None: - """SDK-defined: with an access token in the auth context (as - AuthContextMiddleware sets it), the default binding is that token's client_id.""" + """SDK-defined: with an access token in the auth context, the default binding is its client_id.""" user = AuthenticatedUser(AccessToken(token="at-1", client_id="client-123", scopes=[])) reset = auth_context_var.set(user) try: diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 647c1e600..8f90036be 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -1,12 +1,4 @@ -"""`mcp.server.request_state`: the `RequestStateBoundary` middleware and its claims -envelope, proven through the public wire surfaces — `requestState` is sealed on the way -out, verified and restored on the way back, and every verification failure collapses to -one frozen wire error (MCP 2026-07-28, basic/patterns/mrtr server requirements 4-5). - -Servers here use MANUAL multi-round-trip tools (a plain `@mcp.tool()` returning -`str | InputRequiredResult` that reads `ctx.input_responses` / `ctx.request_state`), -driven by the manual client loop on `client.session.call_tool`. -""" +"""`RequestStateBoundary` end to end: seal outbound, verify and restore inbound, one frozen error on failure.""" import json import logging @@ -50,7 +42,7 @@ pytestmark = pytest.mark.anyio -_KEY = b"0123456789abcdef0123456789abcdef" # 32 bytes; a test fixture, not a secret +_KEY = b"0123456789abcdef0123456789abcdef" # 32 bytes _T0 = 1_782_345_600.0 # frozen mint instant for clock-controlled tests _TTL = 600.0 @@ -74,17 +66,12 @@ def _accept() -> ElicitResult: async def _list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: - """Minimal listing for lowlevel fixtures: `ClientSession.call_tool` consults - tools/list for output-schema validation, so the server must answer it.""" + """`ClientSession.call_tool` consults tools/list, so lowlevel fixtures must answer it.""" return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) class _PassthroughCodec: - """A contract-valid codec with no crypto: the token IS the payload bytes. - - Lets a test place arbitrary payload bytes behind a successful unseal, to - prove the boundary's own claims checks reject what a codec cannot vouch for. - """ + """Cryptography-free codec (the token IS the payload) that puts arbitrary bytes behind a successful unseal.""" def seal(self, payload: bytes) -> str: return payload.decode() @@ -94,7 +81,7 @@ def unseal(self, token: str) -> bytes: class _CustomMethodParams(RequestParams): - """Params for a lowlevel custom (extension-style) method in the off-set tests.""" + """Params for a custom (non-carrier) method.""" request_state: str | None = None @@ -110,18 +97,13 @@ def time(self) -> float: def _tamper(token: str) -> str: - """Flip one mid-token character. Strict canonical decoding means any single-character - change rejects — including the final char, whose don't-care padding bits a lax decoder - would ignore (pinned by the canonicality tests in test_request_state.py).""" + """Flip one mid-token character; strict canonical decoding rejects any single-character change.""" i = len(token) // 2 return token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: - """The single frozen wire shape for every inbound verification failure. - - Frozen contract — asserted explicitly, never snapshotted. - """ + """Assert the single frozen wire shape for every inbound verification failure.""" assert exc.value.error.code == INVALID_PARAMS assert exc.value.error.message == "Invalid or expired requestState" assert exc.value.error.data == {"reason": "invalid_request_state"} @@ -130,9 +112,7 @@ def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: def _manual_server( security: RequestStateSecurity | None, *, state: str = "awaiting-confirm", name: str = "manual" ) -> tuple[MCPServer, list[str | None]]: - """An MCPServer with one manual MRTR tool: round 1 asks, the retry records the - echoed `ctx.request_state` and completes. With `security`, `name` is also the - boundary's default audience; with None no boundary is installed at all.""" + """MCPServer with one manual MRTR tool: round 1 asks, the retry records the echoed `ctx.request_state`.""" seen: list[str | None] = [] mcp = MCPServer(name, request_state_security=security) @@ -166,8 +146,7 @@ async def _retry(client: Client, name: str, args: dict[str, Any], token: str) -> async def test_request_state_is_sealed_on_the_wire_and_restored_for_the_handler() -> None: """Spec-mandated (basic/patterns/mrtr server requirements 4-5): the wire carries an - opaque integrity-protected token — never the handler's plaintext — and a faithful - echo hands the handler back exactly the state it minted.""" + opaque token, never the handler's plaintext, and a faithful echo restores it.""" plaintext = "awaiting-confirm:9f2e" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY]), state=plaintext) @@ -189,8 +168,7 @@ async def test_request_state_is_sealed_on_the_wire_and_restored_for_the_handler( async def test_lowlevel_server_gets_identical_sealing_from_the_one_line_middleware_append() -> None: """Spec-mandated (basic/patterns/mrtr server requirements 4-5): appending the public - `RequestStateBoundary` to `Server.middleware` gives the lowlevel tier the same sealed - wire and the same plaintext restore — nothing is private to MCPServer.""" + `RequestStateBoundary` to `Server.middleware` gives the lowlevel tier the same sealing.""" plaintext = "lowlevel-round-1" seen: list[str | None] = [] @@ -220,9 +198,7 @@ async def call_tool( async def test_a_resource_template_flow_seals_on_resources_read_and_restores_the_plaintext() -> None: """Spec-mandated (basic/patterns/mrtr server requirements 4-5): resources/read is an - MRTR carrier too — a template's `requestState` crosses the wire sealed and bound to - the originating uri, and the faithful retry hands the template function back its - plaintext.""" + MRTR carrier, so a template's `requestState` crosses sealed and bound to the uri.""" plaintext = "resource-round-1" seen: list[str | None] = [] mcp = MCPServer("templated", request_state_security=RequestStateSecurity(keys=[_KEY])) @@ -260,8 +236,8 @@ async def confirm(env: str, ctx: Context) -> str | InputRequiredResult: async def test_tampered_request_state_is_rejected_with_the_frozen_wire_error() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 5): a modified echo fails - authentication and is rejected with the frozen -32602 shape; the handler never runs.""" + """Spec-mandated (basic/patterns/mrtr server requirement 5): a modified echo is + rejected with the frozen -32602 shape and the handler never runs.""" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) with anyio.fail_after(5): @@ -277,8 +253,8 @@ async def test_tampered_request_state_is_rejected_with_the_frozen_wire_error() - async def test_expired_request_state_is_rejected_and_just_inside_ttl_is_accepted( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Spec-mandated (basic/patterns/mrtr server requirements 4-5, expiration bound): one - second past `ttl` is the frozen rejection; one second inside completes the flow.""" + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): one second past `ttl` + is rejected, one second inside completes.""" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) clock = _Clock(_T0) monkeypatch.setattr(request_state_module, "time", clock) @@ -301,7 +277,7 @@ async def test_state_minted_in_the_future_is_rejected_beyond_the_sixty_second_sk monkeypatch: pytest.MonkeyPatch, ) -> None: """Spec-mandated (basic/patterns/mrtr server requirements 4-5): a token minted 120 s - ahead of the verifier's clock is rejected; 30 s ahead is inside the skew allowance.""" + ahead of the verifier's clock is rejected, 30 s ahead is inside the skew allowance.""" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) clock = _Clock(_T0) monkeypatch.setattr(request_state_module, "time", clock) @@ -324,9 +300,8 @@ async def test_state_minted_in_the_future_is_rejected_beyond_the_sixty_second_sk async def test_round_one_state_replayed_on_a_different_tool_is_rejected() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 4, originating-request - binding): a token minted for tool A fails verification when echoed on tool B of the - same server, while the faithful retry on tool A still completes.""" + """Spec-mandated (basic/patterns/mrtr server requirement 4): a token minted for tool + A is rejected when echoed on tool B of the same server.""" seen: list[str | None] = [] def make_tool(state: str) -> Callable[[Context], Awaitable[str | InputRequiredResult]]: @@ -355,9 +330,8 @@ async def tool(ctx: Context) -> str | InputRequiredResult: async def test_retry_with_different_arguments_is_rejected_and_the_original_arguments_complete() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 4, argument binding): the - same tool retried with different arguments is the frozen rejection; the retry that - repeats the original arguments completes.""" + """Spec-mandated (basic/patterns/mrtr server requirement 4): the same tool retried + with different arguments is rejected.""" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) with anyio.fail_after(5): @@ -376,9 +350,8 @@ async def test_retry_with_different_arguments_is_rejected_and_the_original_argum async def test_state_minted_with_a_principal_is_rejected_when_the_verifier_derives_none() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): principal - binding fails closed — state sealed for a principal is rejected by a round on which - `bind_principal` derives none.""" + """Spec-mandated (basic/patterns/mrtr server requirement 4): state sealed for a + principal is rejected when the verifying round derives none.""" principal: list[str | None] = ["alice"] mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) @@ -394,9 +367,8 @@ async def test_state_minted_with_a_principal_is_rejected_when_the_verifier_deriv async def test_state_minted_without_a_principal_is_rejected_when_the_verifier_derives_one() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): the other - drift direction also fails closed — unbound state is rejected once the verifying - round derives a principal.""" + """Spec-mandated (basic/patterns/mrtr server requirement 4): unbound state is + rejected once the verifying round derives a principal.""" principal: list[str | None] = [None] mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) @@ -412,9 +384,8 @@ async def test_state_minted_without_a_principal_is_rejected_when_the_verifier_de async def test_state_for_a_different_principal_is_rejected_and_the_same_principal_completes() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 4, user binding): a token - minted for one principal is rejected when echoed by another, and accepted when the - same principal returns.""" + """Spec-mandated (basic/patterns/mrtr server requirement 4): one principal's token is + rejected when echoed by another and accepted when the same principal returns.""" principal: list[str | None] = ["alice"] mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) @@ -435,8 +406,7 @@ async def test_state_for_a_different_principal_is_rejected_and_the_same_principa async def test_a_principal_binding_that_raises_fails_the_seal_as_an_internal_error( caplog: pytest.LogCaptureFixture, ) -> None: - """SDK-defined fail-safe: a raising `bind_principal` must not mint unbound state — - the round fails as a bare internal error and the traceback stays in the server log.""" + """SDK-defined: a raising `bind_principal` fails the seal as a bare internal error, not an unbound mint.""" def boom(ctx: ServerRequestContext[Any, Any]) -> str | None: raise RuntimeError("identity provider down") @@ -458,16 +428,14 @@ def boom(ctx: ServerRequestContext[Any, Any]) -> str | None: async def test_a_principal_binding_that_raises_fails_the_unseal_with_the_frozen_rejection( caplog: pytest.LogCaptureFixture, ) -> None: - """SDK-defined fail-safe: a `bind_principal` that raises while verifying must not - bypass the frozen contract — the round collapses to the frozen -32602 and the - traceback stays in the server log.""" + """SDK-defined: a `bind_principal` that raises while verifying collapses to the frozen rejection.""" rounds: list[int] = [] def flaky(ctx: ServerRequestContext[Any, Any]) -> str | None: rounds.append(1) if len(rounds) == 1: - return "alice" # the mint succeeds... - raise RuntimeError("identity provider down") # ...the verify round raises + return "alice" # mint round succeeds + raise RuntimeError("identity provider down") # verify round raises mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=flaky)) @@ -483,9 +451,7 @@ def flaky(ctx: ServerRequestContext[Any, Any]) -> str | None: async def test_two_mints_for_the_same_principal_carry_different_salted_principal_claims() -> None: - """SDK-defined: the `p` claim is salted per mint, so two tokens for the same - principal are not linkable by their principal digests (and `p` is present whenever a - principal is bound).""" + """SDK-defined: the `p` claim is salted per mint, so two tokens for the same principal are not linkable.""" mcp, _ = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: "alice")) with anyio.fail_after(5): @@ -505,9 +471,7 @@ async def test_two_mints_for_the_same_principal_carry_different_salted_principal async def test_two_servers_sharing_a_key_reject_each_others_state_via_the_name_audience() -> None: - """SDK-defined: `MCPServer` wires its server name as the boundary's default audience, - so two services sharing (or accidentally reusing) a secret reject each other's state - out of the box — while each still completes its own flow.""" + """SDK-defined: the server name is the default audience, so servers sharing a key reject each other's state.""" mcp_billing, seen_billing = _manual_server(RequestStateSecurity(keys=[_KEY]), name="billing") mcp_payments, seen_payments = _manual_server(RequestStateSecurity(keys=[_KEY]), name="payments") @@ -525,10 +489,7 @@ async def test_two_servers_sharing_a_key_reject_each_others_state_via_the_name_a async def test_audience_presence_drift_is_rejected_in_both_directions() -> None: - """SDK-defined fail-closed: state minted with an audience is rejected by a boundary - expecting none, and audience-unbound state is rejected by a boundary expecting one — - while each boundary still accepts its own mint (lowlevel tier: the audience is the - boundary's `default_audience`, no MCPServer involved).""" + """SDK-defined: audience presence drift is rejected in both directions; each boundary accepts its own mint.""" def make_server(boundary: RequestStateBoundary) -> Server: async def call_tool( @@ -562,8 +523,7 @@ async def call_tool( async def test_an_explicit_policy_audience_overrides_the_server_name_default() -> None: - """SDK-defined: `RequestStateSecurity(audience=...)` wins over the server-name - default — the envelope carries the policy's audience and the flow still completes.""" + """SDK-defined: `RequestStateSecurity(audience=...)` overrides the server-name default.""" mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], audience="prod-fleet"), name="one-box") with anyio.fail_after(5): @@ -581,10 +541,7 @@ async def test_an_explicit_policy_audience_overrides_the_server_name_default() - async def test_claims_envelope_carries_the_documented_fields_and_omits_p_when_unbound() -> None: - """SDK-defined envelope contract: the sealed payload is the documented claims JSON — - version, mint/expiry stamps, originating method/target/argument digest, the audience - (an MCPServer defaults it to the server name), and the plaintext — with no `p` claim - when `bind_principal` returns None.""" + """SDK-defined: the sealed payload is the documented claims JSON; no `p` claim when the principal is None.""" plaintext = "step-one" mcp, _ = _manual_server( RequestStateSecurity(keys=[_KEY], ttl=_TTL, bind_principal=lambda ctx: None), state=plaintext @@ -608,9 +565,7 @@ async def test_claims_envelope_carries_the_documented_fields_and_omits_p_when_un async def test_each_round_is_resealed_with_a_fresh_token_and_a_restamped_iat( monkeypatch: pytest.MonkeyPatch, ) -> None: - """SDK-defined: a multi-round flow reseals every round — round 2's token differs from - round 1's and carries a fresh mint stamp, so `ttl` bounds per-round think time rather - than total flow time.""" + """SDK-defined: every round reseals with a fresh token and `iat`, so `ttl` bounds per-round think time.""" mcp = MCPServer("wizard-server", request_state_security=RequestStateSecurity(keys=[_KEY], ttl=_TTL)) @mcp.tool() @@ -660,11 +615,7 @@ async def wizard(ctx: Context) -> str | InputRequiredResult: async def test_an_unconfigured_mcpserver_passes_request_state_through_verbatim() -> None: - """SDK-defined: an MCPServer constructed without `request_state_security=` installs - no boundary — the deliberate unprotected posture (the spec MAY omit protection when - tampering can cause nothing worse than request failure). The wire carries exactly - the plaintext the manual handler wrote, a verbatim echo is accepted, and the flow - completes.""" + """SDK-defined: an MCPServer without `request_state_security=` passes `requestState` through verbatim.""" plaintext = "plain-wizard-state" mcp, seen = _manual_server(None, state=plaintext) @@ -680,10 +631,7 @@ async def test_an_unconfigured_mcpserver_passes_request_state_through_verbatim() async def test_a_boundary_free_lowlevel_server_passes_request_state_through_verbatim() -> None: - """SDK-defined: the lowlevel tier has no implicit protection — without a - `RequestStateBoundary` in `Server.middleware`, `requestState` crosses the wire as - the handler's plaintext and the echo reaches the handler as sent (the no-middleware - control for the one-line-append test above).""" + """SDK-defined: without a boundary in `Server.middleware`, `requestState` crosses as the handler's plaintext.""" plaintext = "lowlevel-plain-round-1" seen: list[str | None] = [] @@ -712,10 +660,8 @@ async def call_tool( async def test_non_string_inbound_request_state_is_rejected_with_the_frozen_error() -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 5): a structurally invalid - (non-string) `requestState` placed raw in the params fails at the boundary — before - model validation — with the frozen shape; a stateless request still reaches the - handler.""" + """Spec-mandated (basic/patterns/mrtr server requirement 5): a non-string + `requestState` fails at the boundary with the frozen shape.""" calls: list[str] = [] async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: @@ -744,12 +690,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam ], ) async def test_an_explicit_null_request_state_is_treated_as_absent(install_boundary: bool) -> None: - """SDK-defined (spec-aligned): an explicit `"requestState": null` is the field's - absence — a fresh flow, not presented state. The reject-MUST governs PRESENTED state, - and stripping the field is already in any client's power, so the handler runs and - sees None — through an installed boundary (which verifies only presented state) and - on a boundary-free server (where the null parses straight to the model's None) - alike.""" + """SDK-defined: an explicit `"requestState": null` is the field's absence, so the handler runs and sees None.""" seen: list[str | None] = [] async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: @@ -771,12 +712,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam async def test_inbound_request_state_on_a_non_carrier_method_passes_through_unverified() -> None: - """SDK-defined boundary scope: only tools/call, prompts/get, and resources/read are - multi-round-trip carriers, so the boundary never acts on any other method — a - `requestState` member a client places in a custom (extension-style) method's params - reaches the handler exactly as sent, never unsealed and never verified. Such a value - is outside the multi-round-trip protocol and must not be trusted by whatever handles - it.""" + """SDK-defined: only the MRTR carriers are touched; a custom method's `requestState` arrives as sent.""" calls: list[str] = [] async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> dict[str, Any]: @@ -797,10 +733,7 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> async def test_outbound_request_state_on_a_non_carrier_method_is_not_sealed() -> None: - """SDK-defined boundary scope: an input_required-shaped result on a custom method is - outside the multi-round-trip protocol, so an installed boundary leaves its - `requestState` exactly as the handler wrote it — no sealing, no claims envelope - (the carrier methods' seal is pinned by the end-to-end tests above).""" + """SDK-defined: an input_required result on a custom method keeps its `requestState` unsealed.""" async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="ext-handler-plaintext") @@ -817,8 +750,7 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> async def test_an_off_set_input_required_result_without_state_passes_through_untouched() -> None: - """SDK-defined: an input_required-shaped result on a non-carrier method that mints no - `requestState` is not this module's concern — it crosses the boundary unmodified.""" + """SDK-defined: an input_required result on a non-carrier method minting no state crosses unmodified.""" async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: return InputRequiredResult(input_requests={"confirm": _ask("?")}) @@ -841,9 +773,8 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> async def test_a_codec_that_raises_unexpectedly_fails_closed_with_the_frozen_error( caplog: pytest.LogCaptureFixture, ) -> None: - """Spec-mandated fail-safe (basic/patterns/mrtr server requirement 5): a buggy custom - codec denies on error — the wire gets the frozen rejection while the traceback stays - in the server log.""" + """Spec-mandated (basic/patterns/mrtr server requirement 5): a codec that raises + unexpectedly denies with the frozen rejection.""" class ExplodingCodec: def seal(self, payload: bytes) -> str: @@ -860,8 +791,6 @@ def unseal(self, token: str) -> bytes: assert token == "opaque-token" with pytest.raises(MCPError) as exc: await _retry(client, "deploy", {"env": "prod"}, token) - # The exact-match frozen assertions also prove the exception text - # never reached the wire. _assert_frozen_rejection(exc) assert seen == [] @@ -871,9 +800,8 @@ def unseal(self, token: str) -> bytes: async def test_a_codec_reject_reason_reaches_the_log_but_never_the_wire( caplog: pytest.LogCaptureFixture, ) -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 5) plus the SDK log - contract: an `InvalidRequestState` reason from a custom codec is logged server-side - while the wire stays the frozen shape.""" + """Spec-mandated (basic/patterns/mrtr server requirement 5): a custom codec's + `InvalidRequestState` reason is logged server-side, never sent on the wire.""" class RefusingCodec: def seal(self, payload: bytes) -> str: @@ -889,7 +817,6 @@ def unseal(self, token: str) -> bytes: token = await _first_round(client, "deploy", {"env": "prod"}) with pytest.raises(MCPError) as exc: await _retry(client, "deploy", {"env": "prod"}, token) - # Exact-match frozen assertions prove "boom" is not on the wire. _assert_frozen_rejection(exc) assert "boom" in caplog.text @@ -906,11 +833,7 @@ def unseal(self, token: str) -> bytes: ], ) async def test_codec_authenticated_bytes_that_are_not_a_claims_envelope_are_rejected(payload: str) -> None: - """SDK-defined (claims enforcement for every codec): bytes a codec vouches for are - still nothing until they parse as the SDK's claims envelope — non-JSON payloads and - well-formed JSON of the wrong shape both collapse to the frozen rejection before the - handler runs (crafted via a passthrough codec; the built-in AEAD only ever - authenticates envelopes it sealed itself).""" + """SDK-defined: codec-authenticated bytes that are not the claims envelope collapse to the frozen rejection.""" mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=None)) with anyio.fail_after(5): @@ -923,10 +846,7 @@ async def test_codec_authenticated_bytes_that_are_not_a_claims_envelope_are_reje async def test_a_forged_principal_claim_that_is_not_base64_is_rejected() -> None: - """SDK-defined (principal binding): a `p` claim that does not decode as base64 can - never match any principal, so the round collapses to the frozen rejection even - inside a token the codec authenticated (crafted via a passthrough codec; the - built-in AEAD makes the claim untouchable).""" + """SDK-defined: a `p` claim that does not decode as base64 collapses to the frozen rejection.""" mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) with anyio.fail_after(5): @@ -943,10 +863,7 @@ async def test_a_forged_principal_claim_that_is_not_base64_is_rejected() -> None @pytest.mark.parametrize("forged", [pytest.param(7, id="int"), pytest.param({"x": 1}, id="object")]) async def test_a_non_string_principal_claim_is_rejected_with_the_frozen_error(forged: Any) -> None: - """SDK-defined (principal binding): a non-string `p` claim inside a validly-sealed - envelope (possible only through a weak custom codec) collapses to the frozen - rejection — it can never raise past `_reject` and leak exception text onto the - wire.""" + """SDK-defined: a non-string `p` claim inside a validly-sealed envelope collapses to the frozen rejection.""" mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) with anyio.fail_after(5): @@ -968,10 +885,8 @@ async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - """Spec-mandated (basic/patterns/mrtr server requirement 5) plus the SDK log - contract: tampered, expired, and rebound echoes produce byte-identical wire errors - (no failure oracle), the real reasons are logged at WARNING, and no log record ever - carries the token, the plaintext state, or the principal.""" + """Spec-mandated (basic/patterns/mrtr server requirement 5): tampered, expired, and rebound + echoes get identical wire errors, with reasons logged but no secrets in any record.""" plaintext = "secret-plaintext-state-1f9b" principal = "principal-alice-7c3d" mcp, seen = _manual_server( @@ -998,8 +913,8 @@ async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( assert seen == [] reject_logs = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.WARNING] - assert len(reject_logs) == 3 # the real reasons ARE logged... - for record in caplog.records: # ...but never the secrets + assert len(reject_logs) == 3 + for record in caplog.records: message = record.getMessage() assert token not in message assert plaintext not in message @@ -1010,8 +925,7 @@ async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( async def test_a_complete_result_crosses_the_boundary_untouched() -> None: - """SDK-defined: the outbound seam keys off `resultType` — a complete tools/call wire - result passes through as the identical object.""" + """SDK-defined: a complete tools/call wire result passes the boundary as the identical object.""" boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) complete: dict[str, Any] = {"resultType": "complete", "content": []} @@ -1030,9 +944,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_input_required_without_request_state_is_untouched() -> None: - """SDK-defined: sealing keys off the `requestState` field — an `input_required` - result that asks without minting state crosses the boundary unmodified, and the - response-only retry completes.""" + """SDK-defined: an `input_required` result that asks without minting state crosses the boundary unmodified.""" seen: list[str | None] = [] mcp = MCPServer("stateless-ask", request_state_security=RequestStateSecurity(keys=[_KEY])) @@ -1057,10 +969,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: async def test_an_input_required_mapping_with_a_non_string_state_is_not_sealed() -> None: - """SDK-defined: only a middleware short-circuiting below the boundary can put a - non-string `requestState` in a wire mapping (the spec path validates the field as a - string); that value is not state this module minted or seals, so the result crosses - unchanged.""" + """SDK-defined: a non-string `requestState` in a wire mapping is not this module's mint; it crosses unchanged.""" boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) malformed: dict[str, Any] = {"resultType": "input_required", "inputRequests": {}, "requestState": 7} @@ -1079,8 +988,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_notification_crosses_the_boundary_unharmed() -> None: - """SDK-defined: the boundary is inert for notifications — the context reaches - `call_next` as the identical object and the None result comes back unchanged.""" + """SDK-defined: the boundary is inert for notifications.""" boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) forwarded: list[ServerRequestContext[Any, Any]] = [] @@ -1102,8 +1010,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_non_mrtr_method_with_no_params_is_untouched() -> None: - """SDK-defined: methods outside the MRTR trio pass the boundary inert — `tools/list` - with absent params is forwarded and its result returned identically.""" + """SDK-defined: a non-carrier method with absent params passes the boundary inert.""" boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) listing: dict[str, Any] = {"tools": [], "resultType": "complete"} @@ -1125,9 +1032,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_short_circuited_input_required_model_is_sealed_via_the_model_path() -> None: - """SDK-defined: a middleware below the boundary that short-circuits with an - `InputRequiredResult` MODEL (not the serialized wire dict) still has its state - sealed — the boundary returns a copy carrying the sealed token, requests intact.""" + """SDK-defined: a short-circuited `InputRequiredResult` model is sealed via the model path, on a copy.""" boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) interim = InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="model-plaintext") @@ -1151,4 +1056,4 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: assert result.request_state.startswith("v1.") claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(result.request_state)) assert (claims["m"], claims["t"], claims["s"]) == ("tools/call", "shortcut", "model-plaintext") - assert interim.request_state == "model-plaintext" # sealed on a copy; the original is untouched + assert interim.request_state == "model-plaintext" diff --git a/tests/server/test_request_state_gate.py b/tests/server/test_request_state_gate.py index 99d4d393e..61b6aea18 100644 --- a/tests/server/test_request_state_gate.py +++ b/tests/server/test_request_state_gate.py @@ -1,16 +1,11 @@ -"""Startup gate for `request_state_security=` on MCP-server registration funnels -(`mcp.server.request_state` + the `MCPServer` wiring). - -Every test here is synchronous registration-time behavior: no Client, no -connection, no event loop. The gate is resolver-only: a `Resolve(...)` tool's -requestState carries elicited answers — business inputs the SDK itself authors — -so the spec's integrity requirement (basic/patterns/mrtr, server requirements -4-5) is never optional for it, and registering one on a server constructed -without `request_state_security=` fails up front, before any client can -connect. Manual `InputRequiredResult` surfaces (tools, prompts, resource -templates) are not gated: their state is author-written, and an unconfigured -server deliberately passes it through as plaintext (the boundary tests pin that -posture). +"""Startup gate for `request_state_security=` on the MCP-server registration funnels. + +Every test is synchronous registration-time behavior: no Client, no connection, +no event loop. The gate is resolver-only: a `Resolve(...)` tool's requestState +carries elicited answers, which the spec requires to be integrity-protected +(mrtr server requirements 4-5). Manual `InputRequiredResult` surfaces are not +gated; their author-written state passes through an unconfigured server as +plaintext (pinned by the boundary tests). """ from typing import Annotated, Any @@ -26,9 +21,8 @@ from mcp.server.mcpserver.tools import Tool from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity -# Registration fixtures. Only their signatures are inspected at registration; none -# is ever called, so each body is a bare `...` (a constant statement the compiler -# eliminates - nothing for coverage to miss, and pyright treats them as stubs). +# Registration fixtures: only their signatures are inspected and none is ever +# called, so each body is a bare `...` (nothing for coverage to miss). # Resolver for `Resolve(...)` markers: @@ -39,8 +33,7 @@ async def _provide_login(ctx: Context) -> str: ... async def _deploy(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str: ... -# Manual-MRTR tool, prompt, and resource template (declared InputRequiredResult -# returns; not gated): +# Manual-MRTR tool, prompt, and resource template (not gated): async def _confirm_deploy(target: str) -> str | InputRequiredResult: ... @@ -64,10 +57,8 @@ async def _plain_template(id: str) -> str: ... def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> None: - """SDK-defined: a `Resolve(...)` tool's requestState carries elicited answers — - business inputs, squarely inside the spec's integrity MUST (mrtr server reqs 4-5) — - so registering it on a server constructed without `request_state_security=` raises - at the `@mcp.tool()` call with the full teaching text.""" + """SDK-defined: a `Resolve(...)` tool on a server without `request_state_security=` + is rejected at the `@mcp.tool()` call with the full teaching text.""" mcp = MCPServer("gate") with pytest.raises(ValueError) as excinfo: @@ -99,9 +90,7 @@ def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> N def test_constructor_supplied_resolver_tool_bypasses_add_tool_but_is_still_rejected() -> None: - """SDK-defined: `MCPServer(tools=[...])` inserts Tool objects directly into the - ToolManager without going through `add_tool`, so `__init__` must re-scan and reject - an unprotected resolver tool at construction, naming it.""" + """SDK-defined: `MCPServer(tools=[...])` bypasses `add_tool`, so `__init__` re-scans and rejects, naming it.""" tool = Tool.from_function(_deploy, name="deploy") with pytest.raises(ValueError) as excinfo: @@ -111,10 +100,7 @@ def test_constructor_supplied_resolver_tool_bypasses_add_tool_but_is_still_rejec def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: - """SDK-defined: the constructor scan judges a hand-built Tool by its stored - `resolved_params` — the authority that actually drives resolution at call time — - not by re-inspecting `fn`, which a hand-built Tool may carry without any resolver - annotations.""" + """SDK-defined: the constructor scan judges a hand-built Tool by its stored `resolved_params`, not its fn.""" tool = Tool.from_function(_deploy, name="deploy").model_copy(update={"fn": _plain_tool}) with pytest.raises(ValueError) as excinfo: @@ -124,10 +110,7 @@ def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: - """SDK-defined: a hand-built Tool carrying both stored `resolved_params` and an fn - that declares an InputRequiredResult return (a combination `Tool.from_function` - rejects with its own error) is still judged by its stored resolver authority — the - constructor scan raises the resolver gate instead of silently admitting it.""" + """SDK-defined: a hand-built Tool whose `resolved_params` and fn disagree is judged by the stored authority.""" tool = Tool.from_function(_deploy, name="combo").model_copy(update={"fn": _confirm_deploy}) with pytest.raises(ValueError) as excinfo: @@ -137,11 +120,7 @@ def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: def test_decorator_combo_fn_on_an_unconfigured_server_raises_the_resolver_gate_error() -> None: - """SDK-defined: the `add_tool` gate scans the function before `Tool.from_function` - runs, so a function combining `Resolve(...)` parameters with a declared - `InputRequiredResult` return raises the resolver-security error on an unconfigured - server; configuring security then surfaces `Tool.from_function`'s own - `InvalidSignature` for the combination (pinned in test_resolve.py).""" + """SDK-defined: the `add_tool` gate runs before `Tool.from_function`, so the combo fn raises the resolver error.""" mcp = MCPServer("gate") async def combo(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str | InputRequiredResult: ... @@ -153,11 +132,7 @@ async def combo(target: str, login: Annotated[str, Resolve(_provide_login)]) -> def test_declared_manual_surfaces_register_cleanly_on_an_unconfigured_server() -> None: - """SDK-defined: declared manual surfaces — a tool, prompt, or resource template - annotated `-> ... | InputRequiredResult` — are NOT gated: their state is - author-written, so every funnel (decorator, constructor `tools=`, `add_prompt`) - registers cleanly on a server with no `request_state_security=`. The unconfigured - server passes their state through as plaintext (pinned in the boundary tests).""" + """SDK-defined: declared manual surfaces are not gated; every funnel registers them on an unconfigured server.""" mcp = MCPServer("gate", tools=[Tool.from_function(_confirm_deploy, name="ctor_confirm_deploy")]) mcp.tool(name="confirm_deploy")(_confirm_deploy) @@ -173,9 +148,7 @@ def test_declared_manual_surfaces_register_cleanly_on_an_unconfigured_server() - def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> None: - """SDK-defined: with `request_state_security=` supplied, the resolver tools the gate - rejects register cleanly — and so does every other MRTR surface, across every funnel - (constructor `tools=`, tool and prompt and resource decorators, `add_prompt`).""" + """SDK-defined: with `request_state_security=` supplied, every MRTR surface registers via every funnel.""" mcp = MCPServer( "gate", request_state_security=RequestStateSecurity.ephemeral(), @@ -194,9 +167,7 @@ def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> N def test_mrtr_free_registrations_need_no_security_configuration() -> None: - """SDK-defined: the gate keys on `Resolve(...)` usage, so plain tools (decorator and - constructor-supplied), prompts, and resources register on an unconfigured server - exactly as before — this pins the gate against over-firing.""" + """SDK-defined: the gate keys on `Resolve(...)` usage; MRTR-free registrations work exactly as before.""" mcp = MCPServer("gate", tools=[Tool.from_function(_plain_tool, name="ctor_plain_tool")]) mcp.tool(name="plain_tool")(_plain_tool) @@ -212,9 +183,7 @@ def test_mrtr_free_registrations_need_no_security_configuration() -> None: def test_security_with_zero_mrtr_registrations_is_legal_and_inert() -> None: - """SDK-defined: configuring `request_state_security=` on a server that registers - no MRTR-capable surface is legal — the policy sits inert rather than demanding - MRTR usage.""" + """SDK-defined: `request_state_security=` with no MRTR-capable registration is legal and inert.""" mcp = MCPServer("gate", request_state_security=RequestStateSecurity.ephemeral()) mcp.tool(name="plain_tool")(_plain_tool) @@ -223,10 +192,7 @@ def test_security_with_zero_mrtr_registrations_is_legal_and_inert() -> None: def test_lowlevel_server_has_no_gate_and_takes_the_boundary_as_ordinary_middleware() -> None: - """SDK-defined: the lowlevel tier cannot see MRTR capability (handlers are opaque - callables), so `Server` accepts an input_required-returning handler freely and - protection is explicit — appending a `RequestStateBoundary` to `Server.middleware` - grows the chain by one.""" + """SDK-defined: the lowlevel `Server` has no gate; protection is an explicit `RequestStateBoundary` middleware.""" # Handler fixture: lowlevel registration neither inspects nor runs it here. async def call_tool( @@ -242,9 +208,7 @@ async def call_tool( def test_extension_contributed_resolver_tool_is_gated_through_add_tool() -> None: - """SDK-defined: extension tools register through `MCPServer.add_tool`, so an - extension whose `tools()` yields a `Resolve(...)` tool trips the gate when the - host server has no `request_state_security=`.""" + """SDK-defined: extension tools register through `MCPServer.add_tool`, so the gate covers them.""" class ResolverExt(Extension): identifier = "com.example/resolver" @@ -259,9 +223,7 @@ def tools(self) -> list[ToolBinding]: def test_the_gate_fires_in_the_synchronous_registration_frame_not_at_first_request() -> None: - """SDK-defined: rejection happens at the registration call itself — this module - creates no Client, opens no connection, and runs no event loop — and a rejected - registration leaves the server usable for further registrations.""" + """SDK-defined: rejection happens in the registration frame and leaves the server usable afterward.""" mcp = MCPServer("gate") with pytest.raises(ValueError): diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index e796324a0..126e06c29 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -554,14 +554,12 @@ def test_cacheable_methods_mirror_the_cacheable_method_literal(): def test_input_required_methods_mirror_the_monolith_input_required_arms(): - """MRTR weld: the set derived from `MONOLITH_RESULTS` is exactly the spec's three - multi-round-trip carriers — the only methods whose results may be input_required.""" + """MRTR weld: the spec's three multi-round-trip carriers are the only input_required methods.""" assert methods.INPUT_REQUIRED_METHODS == frozenset({"prompts/get", "resources/read", "tools/call"}) def test_is_input_required_matches_typed_and_wire_shapes(): - """The shared interim-result predicate: True for the `InputRequiredResult` model and - for a wire mapping tagged `resultType: "input_required"`; False for everything else.""" + """SDK-defined predicate: True only for the typed model and the tagged wire mapping.""" assert methods.is_input_required(types.InputRequiredResult(request_state="s")) assert methods.is_input_required({"resultType": "input_required", "inputRequests": {}}) assert not methods.is_input_required({"resultType": "complete", "content": []}) From 471cdd9a4285ce86e5ff028597aafd7032d59f55 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:14:37 +0000 Subject: [PATCH 5/8] Address review feedback on requestState protection - Pin pending questions to their rendered wording: an answer arriving after a redeploy reworded the question is re-asked, not consumed. The state envelope (now v3) records the digest of every question asked, and discarded answers are logged. - Make every encode on the state path total via one compact_json helper, and parse state with stdlib json so escaped lone surrogates round-trip; surrogate-bearing arguments digest instead of failing the round. - Complete the deny-on-error discipline: a raising codec seal and non-string bind_principal returns fail closed in both directions through one shared _bound_principal helper. - Mint envelope timestamps on the float clock so the configured ttl is the effective ttl; sub-second ttls were expired at mint. - Bind the default principal to the token's (client_id, issuer, subject) through the shared principal_components, the same identity session ownership uses; two users of one OAuth client are distinct principals. - Require an explicit audience source: RequestStateBoundary takes default_audience explicitly, and a configured MCPServer must have a real (non-empty) name or set audience=. - Make the tutorial codec example enforce the strict token contract (prefix check, canonical hex). --- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 10 +- docs/migration.md | 2 +- docs_src/mrtr/tutorial005.py | 7 +- examples/stories/mrtr/README.md | 3 +- examples/stories/mrtr/server_lowlevel.py | 5 +- src/mcp/server/auth/middleware/bearer_auth.py | 11 +- src/mcp/server/auth/provider.py | 11 ++ src/mcp/server/mcpserver/resolve.py | 69 +++++--- src/mcp/server/mcpserver/server.py | 12 ++ src/mcp/server/request_state.py | 97 ++++++++--- tests/docs_src/test_mrtr.py | 14 ++ .../auth/middleware/test_bearer_auth.py | 27 ++- tests/server/auth/test_provider.py | 13 +- tests/server/mcpserver/test_resolve.py | 164 ++++++++++++++++-- tests/server/test_request_state.py | 64 ++++++- tests/server/test_request_state_boundary.py | 156 +++++++++++++++-- tests/server/test_request_state_gate.py | 27 ++- 18 files changed, 595 insertions(+), 99 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 8accd4e22..1d1923ea8 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...])))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index a8fa256df..523c2bade 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -112,9 +112,9 @@ mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral()) With either built-in configuration, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: * **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. -* **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. +* **The authenticated principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected. * **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails. -* **The exact question asked.** A recorded resolver answer is pinned to the rendered question the client was shown. Redeploy with a reworded message or a changed schema and the server re-asks instead of reusing a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. +* **The exact question asked.** Every resolver answer is pinned to the rendered question the client was shown, both on the round it first arrives and when a recorded answer is reused later. Redeploy with a reworded message or a changed schema and the server re-asks instead of consuming a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. All of that is the SDK's job, not yours, and not the codec's if you bring your own. @@ -130,13 +130,13 @@ RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout. -Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim by default, so a token minted by a different service that happens to share a secret is rejected anyway. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted. +Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, which is why `MCPServer` refuses `request_state_security=` on an unnamed server. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted. ### Bring your own crypto `RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local: -```python title="server.py" hl_lines="12 29-30 33" +```python title="server.py" hl_lines="12 26-27 34-35 38" --8<-- "docs_src/mrtr/tutorial005.py" ``` @@ -184,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. -* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated client when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). +* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 4d07bdc7a..351be2202 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -433,7 +433,7 @@ from mcp.server.mcpserver import MCPServer, RequestStateSecurity mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral()) ``` -Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). +Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. A configured server must also be named (or pass `RequestStateSecurity(audience=...)`): the name becomes the sealed token's audience claim, so an unnamed server raises `ValueError` at construction. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote. Sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too. diff --git a/docs_src/mrtr/tutorial005.py b/docs_src/mrtr/tutorial005.py index af2d76054..a8588b250 100644 --- a/docs_src/mrtr/tutorial005.py +++ b/docs_src/mrtr/tutorial005.py @@ -23,8 +23,13 @@ def seal(self, payload: bytes) -> str: return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex() def unseal(self, token: str) -> bytes: + if not token.startswith(PREFIX): + raise InvalidRequestState("unknown token format") + body = token[len(PREFIX) :] try: - raw = bytes.fromhex(token.removeprefix(PREFIX)) + raw = bytes.fromhex(body) + if raw.hex() != body: # only the exact string seal() produced verifies + raise ValueError("non-canonical hex") return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode()) except (ValueError, InvalidTag) as exc: raise InvalidRequestState("token failed verification") from exc diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 5a735c32b..3da3e6e43 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -56,7 +56,8 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel then completes the round normally. - `server_lowlevel.py`: the lowlevel tier has no construction-time requirement; the same enforcement is one appended middleware: - `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral()))`. + `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), + default_audience=server.name))`. ## Caveats diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py index e10f77ba8..57b2f3993 100644 --- a/examples/stories/mrtr/server_lowlevel.py +++ b/examples/stories/mrtr/server_lowlevel.py @@ -57,8 +57,9 @@ async def call_tool( return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) - # Lowlevel opt-in: append the same boundary middleware MCPServer installs from request_state_security=. - server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral())) + # Lowlevel opt-in: append the same boundary middleware MCPServer installs from + # request_state_security=; the server name becomes the token audience. + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) return server diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index ba66e9422..29413abf2 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -7,7 +7,7 @@ from starlette.requests import HTTPConnection from starlette.types import Receive, Scope, Send -from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components class AuthenticatedUser(SimpleUser): @@ -34,13 +34,8 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for a verifier that populates `subject` and `claims` from an introspection response.""" - token = user.access_token - issuer = (token.claims or {}).get("iss") - return AuthorizationContext( - client_id=token.client_id, - issuer=str(issuer) if issuer is not None else None, - subject=token.subject, - ) + client_id, issuer, subject = principal_components(user.access_token) + return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject) class BearerAuthBackend(AuthenticationBackend): diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index eeb371f1c..644868f3e 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -59,6 +59,17 @@ class AccessToken(BaseModel): claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) +def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]: + """The (client_id, issuer, subject) triple identifying the principal a token represents. + + The single source for "who is this token's principal": session ownership and + request-state binding both build on it. Components the token verifier does + not supply are `None`, so comparisons degrade to the remaining components. + """ + issuer = (token.claims or {}).get("iss") + return token.client_id, str(issuer) if issuer is not None else None, token.subject + + RegistrationErrorCode = Literal[ "invalid_redirect_uri", "invalid_client_metadata", diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index a6b9628cb..d752afc10 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -31,6 +31,8 @@ import base64 import hashlib import inspect +import json +import logging import types import typing from collections.abc import Callable, Hashable, Mapping @@ -45,6 +47,7 @@ ElicitRequestFormParams, ElicitResult, FormElicitationCapability, + InputRequest, InputRequests, InputRequiredResult, InputResponses, @@ -63,6 +66,7 @@ ) from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.server.request_state import compact_json from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError @@ -75,7 +79,9 @@ # `InputRequiredResult` rather than as a standalone server-to-client request. # Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added). _INPUT_REQUIRED_VERSION = "2026-07-28" -_STATE_VERSION = 2 # v2 adds per-entry question digests +_STATE_VERSION = 3 # v3: recorded and pended outcomes pinned to ASCII-canonical question renders + +logger = logging.getLogger(__name__) class Resolve: @@ -371,7 +377,11 @@ def __init__( self.context = context self.input_required = input_required self.answers: InputResponses = context.input_responses or {} if input_required else {} - self.state = _decode_state(context.request_state) if input_required else {} + decoded = _decode_state(context.request_state if input_required else None) + self.state = decoded.outcomes + # Digests of the questions asked last round: an answer is accepted only + # for the exact rendering the client was shown. + self.asked = decoded.asked # In-call dedup keyed by resolver identity (distinguishes two instances of # the same bound method); `persist` holds the wire-shaped record of each # elicited outcome, keyed by its wire key - exactly what the next round's @@ -433,7 +443,8 @@ async def resolve_arguments( injected[name] = outcome if wants_union else _unwrap(outcome, name) if res.pending: - return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist)) + asked = {key: _request_digest(request) for key, request in res.pending.items()} + return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist, asked)) return injected @@ -496,7 +507,8 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio if not res.input_required: return await res.context.elicit(elicit.message, elicit.schema) - q = _question_digest(elicit) + request = _elicit_request(elicit) + q = _request_digest(request) # A recorded outcome from a prior round is consulted only here, after the body # decided to ask, so a `request_state` entry can never stand in for a resolver's @@ -506,9 +518,14 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio return outcome answer = res.answers.get(key) + # An answer counts only for the rendering recorded when it was asked; an answer to + # an unrecorded or differently-worded question re-asks instead of being consumed. + if answer is not None and res.asked.get(key) != q: + logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key) + answer = None if answer is None: _require_form_elicitation(res.context, key) - res.pending[key] = _elicit_request(elicit) + res.pending[key] = request raise _Pending if not isinstance(answer, ElicitResult): raise ToolError(f"Resolver {key!r} received a non-elicitation response") @@ -601,46 +618,54 @@ class _StateEntry(BaseModel): """Digest of the exact rendered question this outcome answered.""" -def _question_digest(elicit: Elicit[Any]) -> str: +def _request_digest(request: InputRequest) -> str: """Pin an outcome to the exact rendered question the client was shown. A redeploy that rewords or reshapes a question re-asks it instead of reusing the recorded answer. """ - rendered = _elicit_request(elicit).params.model_dump_json(by_alias=True, exclude_none=True) + params = request.params + rendered = compact_json(params.model_dump(mode="json", by_alias=True, exclude_none=True) if params else None) digest = hashlib.sha256(rendered.encode()).digest()[:16] return base64.urlsafe_b64encode(digest).decode().rstrip("=") class _State(BaseModel): - """The decoded `request_state`: resolver outcomes from earlier rounds.""" + """The decoded `request_state`: resolver progress from earlier rounds.""" v: int outcomes: dict[str, _StateEntry] = {} + asked: dict[str, str] = {} + """Question digest of each elicitation asked last round, keyed by wire key.""" -def _decode_state(request_state: str | None) -> dict[str, _StateEntry]: +def _decode_state(request_state: str | None) -> _State: """Decode the per-call resolution progress from `request_state`. - The string arrives boundary-authenticated, so malformed content or a - version mismatch is drift within the operator's own fleet (e.g. a rolling - upgrade) and is treated as "no progress yet". + Parsed with stdlib `json.loads` because `_encode_state` may emit escaped + lone surrogates, which pydantic's JSON parser rejects. The string arrives + boundary-authenticated, so malformed content or a version mismatch is + drift within the operator's own fleet (e.g. a rolling upgrade) and is + treated as "no progress yet". """ + empty = _State(v=_STATE_VERSION) if not request_state: - return {} + return empty try: - state = _State.model_validate_json(request_state) - except ValidationError: - return {} - return state.outcomes if state.v == _STATE_VERSION else {} + state = _State.model_validate(json.loads(request_state)) + except ValueError: + return empty + return state if state.v == _STATE_VERSION else empty -def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str: - """Encode recorded elicitation outcomes (keyed by wire key) for the next round. +def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str: + """Encode recorded outcomes and asked-question digests for the next round. - Entries already hold the client's wire-shaped data exactly as it was sent (and - validated), so encoding is pure wrapping: encode-restore is the identity. + Outcome entries already hold the client's wire-shaped data exactly as it was + sent (and validated), so encoding is pure wrapping: encode-restore is the + identity. """ - return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json() + state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked)) + return compact_json(state.model_dump(mode="json")) def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]: diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 042ee2d8e..65f3cb24f 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -174,6 +174,15 @@ def _format_missing_security(owner: str) -> str: ) +_MISSING_AUDIENCE = ( + "request_state_security is configured but this server has no name. Sealed\n" + "requestState carries the server name as an audience claim, so state minted by\n" + "another service that shares the same keys is rejected; unnamed servers would\n" + "all stamp the same placeholder and the check would mean nothing. Name the\n" + 'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).' +) + + class MCPServer(Generic[LifespanResultT]): def __init__( self, @@ -244,6 +253,9 @@ def __init__( # Ordering: inside OpenTelemetry (spans record the sealed wire form), # outside extension interceptors (extensions see plaintext). if request_state_security is not None: + # `not name` mirrors the `name or "mcp-server"` fallback: any falsy name gets the placeholder. + if not name and request_state_security.audience is None: + raise ValueError(_MISSING_AUDIENCE) self._lowlevel_server.middleware.append( RequestStateBoundary(request_state_security, default_audience=self.name) ) diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py index c003a6a72..27cd467e3 100644 --- a/src/mcp/server/request_state.py +++ b/src/mcp/server/request_state.py @@ -27,6 +27,7 @@ from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import principal_components from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.shared.exceptions import MCPError @@ -77,12 +78,17 @@ def unseal(self, token: str) -> bytes: def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: - """Default principal binding: the authenticated OAuth client's `client_id`. + """Default principal binding: the authenticated (client, issuer, subject) identity. + Uses the same components session ownership uses, so two users of one OAuth + client are distinct principals whenever the token verifier supplies a + subject, and the binding degrades to the client identity when it does not. Returns `None` (state not principal-bound) on unauthenticated transports. """ token = get_access_token() - return token.client_id if token is not None else None + if token is None: + return None + return compact_json(principal_components(token)) class RequestStateSecurity: @@ -148,6 +154,17 @@ def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> Reques _NONCE_LEN = 12 +def compact_json(value: Any, *, sort_keys: bool = False) -> str: + """Canonical JSON for everything the state path digests or seals. + + ASCII output keeps the encode total: a lone surrogate in client-supplied + text escapes instead of raising. Anything consuming this must parse with + stdlib `json.loads`, which accepts those escapes (pydantic's JSON parser + does not). + """ + return json.dumps(value, sort_keys=sort_keys, separators=(",", ":")) + + def _b64u(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode().rstrip("=") @@ -260,13 +277,12 @@ def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[st target = str(p.get("uri", "")) else: target, args = str(p.get("name", "")), p.get("arguments") or args - canonical = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False) - return target, _b64u(hashlib.sha256(canonical.encode()).digest()[:16]) + return target, _b64u(hashlib.sha256(compact_json(args, sort_keys=True).encode()).digest()[:16]) def _principal_claim(principal: str) -> str: salt = os.urandom(8) - tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + principal.encode()).digest()[:16] + tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + _principal_bytes(principal)).digest()[:16] return _b64u(salt + tag) @@ -276,10 +292,36 @@ def _principal_matches(claim: str, principal: str) -> bool: except ValueError: return False # A wrong-length claim never matches: compare_digest handles mismatched sizes. - expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + principal.encode()).digest()[:16] + expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + _principal_bytes(principal)).digest()[:16] return hmac.compare_digest(raw[8:], expected) +def _principal_bytes(principal: str) -> bytes: + # The digest input is one-way and never decoded, so surrogatepass keeps it total. + return principal.encode("utf-8", "surrogatepass") + + +def _bound_principal( + security: RequestStateSecurity, + ctx: ServerRequestContext[Any, Any], + fail: Callable[[str], NoReturn], +) -> str | None: + """Run `bind_principal` under the deny-on-error discipline, in one place for both directions. + + `fail` converts a failure into the calling direction's wire shape: the + frozen rejection when verifying, the sanitized internal error when sealing. + """ + try: + principal = security.bind_principal(ctx) if security.bind_principal is not None else None + except Exception: # deny-on-error: a raising principal binding must fail closed + logger.exception("bind_principal raised while processing requestState on %s", ctx.method) + fail("principal binding error") + # The declared return type is str | None, but a user callback can ignore it. + if principal is not None and not isinstance(cast("object", principal), str): + fail(f"bind_principal returned {type(principal).__name__}, expected str or None") + return principal + + class RequestStateBoundary: """Server middleware sealing/unsealing `requestState` at the wire boundary. @@ -293,13 +335,15 @@ class RequestStateBoundary: `input_required` result carrying `requestState` is sealed in a fresh claims envelope; handlers and resolvers never call the codec. - `default_audience` seeds the audience claim when the policy sets none - (`MCPServer` passes its server name). `MCPServer` installs this when - `request_state_security=` is supplied; lowlevel `Server` users append one - to `server.middleware` for identical enforcement. + `default_audience` seeds the audience claim when the policy sets none, and + must be stated explicitly: it is the service identity that stops state + minted by another service sharing the same keys. `MCPServer` installs this + middleware with its server name when `request_state_security=` is supplied; + lowlevel `Server` users append one to `server.middleware`, passing their + server's name (or `None` to deliberately leave tokens audience-free). """ - def __init__(self, security: RequestStateSecurity, *, default_audience: str | None = None) -> None: + def __init__(self, security: RequestStateSecurity, *, default_audience: str | None) -> None: self._security = security self._audience = security.audience if security.audience is not None else default_audience @@ -345,11 +389,11 @@ def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBindi _reject(ctx.method, "request binding") if claims.get("aud") != self._audience: _reject(ctx.method, "audience") - try: - principal = security.bind_principal(ctx) if security.bind_principal is not None else None - except Exception: # deny-on-error: a raising principal binding must fail closed - logger.exception("bind_principal raised while verifying requestState on %s", ctx.method) - _reject(ctx.method, "principal binding error") + + def fail_verify(reason: str) -> NoReturn: + _reject(ctx.method, reason) + + principal = _bound_principal(security, ctx, fail_verify) claim = claims.get("p") if (claim is None) != (principal is None): _reject(ctx.method, "principal drift") @@ -377,15 +421,15 @@ def _seal_result( def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str: security = self._security if binding is None: + + def fail_seal(reason: str) -> NoReturn: + logger.error("refusing to seal requestState on %s: %s", ctx.method, reason) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") + target, args_digest = _request_identity(ctx.method, ctx.params) - try: - principal = security.bind_principal(ctx) if security.bind_principal is not None else None - except Exception: # deny-on-error: a raising principal binding must not mint unbound state - logger.exception("bind_principal raised while sealing requestState on %s", ctx.method) - raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None - binding = (target, args_digest, principal) + binding = (target, args_digest, _bound_principal(security, ctx, fail_seal)) target, args_digest, principal = binding - now = int(time.time()) + now = time.time() claims: dict[str, Any] = { "v": _ENVELOPE_VERSION, "iat": now, @@ -399,4 +443,9 @@ def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _Round claims["aud"] = self._audience if principal is not None: claims["p"] = _principal_claim(principal) - return security.codec.seal(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode()) + payload = compact_json(claims).encode() + try: + return security.codec.seal(payload) + except Exception: # deny-on-error: a raising custom codec must not leak its failure + logger.exception("requestState codec raised during seal on %s", ctx.method) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 1a47302ad..cf7842b0a 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -181,3 +181,17 @@ def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None codec.unseal(token + "00") with pytest.raises(InvalidRequestState): codec.unseal("not-a-token") + + +def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None: + """tutorial005: only the exact minted string verifies; rewritten spellings of it do not.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + body = token.removeprefix(tutorial005.PREFIX) + for alias in ( + body, # prefix stripped + tutorial005.PREFIX + body.upper(), # non-canonical hex case + tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip + ): + with pytest.raises(InvalidRequestState): + codec.unseal(alias) diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index bd14e294c..6ab343677 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -9,8 +9,18 @@ from starlette.requests import Request from starlette.types import Message, Receive, Scope, Send -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend, RequireAuthMiddleware -from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, ProviderTokenVerifier +from mcp.server.auth.middleware.bearer_auth import ( + AuthenticatedUser, + BearerAuthBackend, + RequireAuthMiddleware, + authorization_context, +) +from mcp.server.auth.provider import ( + AccessToken, + OAuthAuthorizationServerProvider, + ProviderTokenVerifier, + principal_components, +) class MockOAuthProvider: @@ -446,3 +456,16 @@ async def send(message: Message) -> None: # pragma: no cover assert app.scope == scope assert app.receive == receive assert app.send == send + + +def test_authorization_context_is_built_from_principal_components() -> None: + """Session ownership identifies the principal via the shared principal_components triple.""" + token = AccessToken( + token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + client_id, issuer, subject = principal_components(token) + assert authorization_context(AuthenticatedUser(token)) == { + "client_id": client_id, + "issuer": issuer, + "subject": subject, + } diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py index aaaeb413a..8c07d02ac 100644 --- a/tests/server/auth/test_provider.py +++ b/tests/server/auth/test_provider.py @@ -1,6 +1,6 @@ """Tests for mcp.server.auth.provider module.""" -from mcp.server.auth.provider import construct_redirect_uri +from mcp.server.auth.provider import AccessToken, construct_redirect_uri, principal_components def test_construct_redirect_uri_no_existing_params(): @@ -77,3 +77,14 @@ def test_construct_redirect_uri_encoded_values(): # urlencode uses + for spaces by default assert "state=test+state+with+spaces" in result + + +def test_principal_components_composes_client_issuer_subject(): + """The triple identifying a token's principal, degrading per missing component.""" + bare = AccessToken(token="t", client_id="client-1", scopes=[]) + assert principal_components(bare) == ("client-1", None, None) + + full = AccessToken( + token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + assert principal_components(full) == ("client-1", "https://as.example", "alice") diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index c414dd1c9..d55c7d9bb 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -41,9 +41,10 @@ from mcp.server.mcpserver.resolve import ( _check_elicit_return, _decode_state, + _elicit_request, _encode_state, _outcome_from_state, - _question_digest, + _request_digest, _resolver_key, _state_key, _StateEntry, @@ -55,6 +56,11 @@ from mcp.shared.exceptions import MCPError +def _question_digest(elicit: Elicit[Any]) -> str: + """The digest `_elicit` pins: the rendered request the client would be shown.""" + return _request_digest(_elicit_request(elicit)) + + class Login(BaseModel): username: str @@ -916,7 +922,8 @@ def test_uses_input_required_version_gate(): ], ) def test_decode_state_tolerates_malformed_request_state(request_state: str | None): - assert _decode_state(request_state) == {} + state = _decode_state(request_state) + assert state.outcomes == {} and state.asked == {} def test_state_round_trips_accept_decline_cancel(): @@ -926,8 +933,10 @@ def test_state_round_trips_accept_decline_cancel(): "c": _StateEntry(action="cancel"), "d": _StateEntry(action="accept", data="raw-token"), # non-dict wire value } - decoded = _decode_state(_encode_state(entries)) + state = _decode_state(_encode_state(entries, {"e": "asked-digest"})) + decoded = state.outcomes assert decoded == entries # encode-restore is the identity on the stored entries + assert state.asked == {"e": "asked-digest"} accepted = _outcome_from_state(decoded["a"], Login) assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") @@ -1689,7 +1698,7 @@ async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: # A decodable v2 entry; the resolver never asks, so it must go unconsulted, not dropped as malformed. entry = {"action": "accept", "data": {"needed": True}, "q": _question_digest(Elicit("Restock?", Restock))} - crafted = json.dumps({"v": 2, "outcomes": {_wire_key(decide): entry}}) + crafted = json.dumps({"v": 3, "outcomes": {_wire_key(decide): entry}}) async with Client(mcp, elicitation_callback=_never) as client: result = await client.session.call_tool( @@ -1720,7 +1729,7 @@ async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str: # A decodable v2 entry: `lookup` never asks, so no digest can make the decline apply. entry = {"action": "decline", "q": _question_digest(Elicit("user?", Login))} - crafted = json.dumps({"v": 2, "outcomes": {_wire_key(lookup): entry}}) + crafted = json.dumps({"v": 3, "outcomes": {_wire_key(lookup): entry}}) async with Client(mcp, elicitation_callback=_never) as client: result = await client.session.call_tool( @@ -1871,18 +1880,18 @@ def test_question_digest_pins_the_rendered_question(): assert len(digest) == 22 and "=" not in digest -def test_state_round_trips_question_digests_at_v2(): +def test_state_round_trips_question_digests_at_v3(): # v2 carries digests for every action and round-trips exactly; v1 (mid rolling deploy) reads as no progress. entries = { "a": _StateEntry(action="accept", data={"username": "octocat"}, q="qa"), "b": _StateEntry(action="decline", q="qb"), "c": _StateEntry(action="cancel", q="qc"), } - encoded = _encode_state(entries) - assert json.loads(encoded)["v"] == 2 - assert _decode_state(encoded) == entries + encoded = _encode_state(entries, {}) + assert json.loads(encoded)["v"] == 3 + assert _decode_state(encoded).outcomes == entries v1 = json.dumps({"v": 1, "outcomes": {"a": {"action": "decline"}}}) - assert _decode_state(v1) == {} + assert _decode_state(v1).outcomes == {} @pytest.mark.anyio @@ -2042,7 +2051,7 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: # Schema-valid accept data under the live key, but no "q" pin. entry = {"action": "accept", "data": {"username": "spooky"}} - crafted = json.dumps({"v": 2, "outcomes": {key: entry}}) + crafted = json.dumps({"v": 3, "outcomes": {key: entry}}) second = await client.session.call_tool( "whoami", {}, @@ -2193,3 +2202,136 @@ async def act( assert isinstance(final, CallToolResult) assert isinstance(final.content[0], TextContent) assert final.content[0].text == "accepted:octocat" + + +@pytest.mark.anyio +async def test_reworded_question_reasks_even_when_the_answer_first_arrives(): + # The pend round records each question's digest in the state, so an answer that + # first arrives after a reword (redeploy between ask and retry) re-asks instead + # of being consumed as consent to the new wording. + mcp = MCPServer(name="RewordArrival", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + wording = {"deploy": "Deploy to prod?"} + + async def ask_deploy(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["deploy"], Confirm) + + @mcp.tool() + async def act(deploy: Annotated[Confirm, Resolve(ask_deploy)]) -> str: + return f"deployed:{deploy.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + pended = json.loads(_unseal_inner(first.request_state))["asked"] + assert pended == {_wire_key(ask_deploy): _question_digest(Elicit("Deploy to prod?", Confirm))} + + wording["deploy"] = "Deploy to staging?" + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + # The stale answer to the old wording is not consumed; the reworded question is asked. + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + question = second.input_requests[_wire_key(ask_deploy)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Deploy to staging?" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "deployed:True" + + +@pytest.mark.anyio +async def test_an_answer_without_the_echoed_state_is_reasked_not_consumed(): + # Without the echoed state there is no record of which question the client was + # shown, so an answer arriving stateless re-asks instead of being consumed. + mcp = MCPServer(name="Stateless", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit("Proceed?", Confirm) + + @mcp.tool() + async def act(go: Annotated[Confirm, Resolve(ask)]) -> str: + return f"went:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "went:True" + + +@pytest.mark.anyio +async def test_recorded_answer_containing_a_lone_surrogate_survives_to_later_rounds(): + # The state encoder escapes lone surrogates, so the decoder must parse them back: + # a recorded answer with one must restore on the next round, not silently re-ask. + mcp = MCPServer(name="Surrogate", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + @mcp.tool() + async def act( + name: Annotated[Login, Resolve(ask_name)], + go: Annotated[Confirm, Resolve(ask_confirm)], + ) -> str: + return f"{name.username}:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "oc\ud800t"})}, + request_state=first.request_state, + allow_input_required=True, + ) + # The surrogate-bearing answer is recorded; only the unanswered question remains. + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + assert set(second.input_requests) == {_wire_key(ask_confirm)} + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_confirm): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "oc\ud800t:True" diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py index 621602c9a..473907b54 100644 --- a/tests/server/test_request_state.py +++ b/tests/server/test_request_state.py @@ -9,8 +9,8 @@ from inline_snapshot import snapshot from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, authorization_context +from mcp.server.auth.provider import AccessToken, principal_components from mcp.server.context import ServerRequestContext from mcp.server.request_state import ( AESGCMRequestStateCodec, @@ -419,11 +419,61 @@ def test_authenticated_principal_is_none_without_an_auth_context() -> None: assert authenticated_principal(_bare_context()) is None -def test_authenticated_principal_returns_the_access_tokens_client_id() -> None: - """SDK-defined: with an access token in the auth context, the default binding is its client_id.""" - user = AuthenticatedUser(AccessToken(token="at-1", client_id="client-123", scopes=[])) - reset = auth_context_var.set(user) +@pytest.mark.parametrize( + ("token", "expected"), + [ + pytest.param( + AccessToken(token="at-1", client_id="client-123", scopes=[]), + '["client-123",null,null]', + id="client-only", + ), + pytest.param( + AccessToken(token="at-2", client_id="client-123", scopes=[], subject="alice"), + '["client-123",null,"alice"]', + id="with-subject", + ), + pytest.param( + AccessToken( + token="at-3", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ), + '["client-123","https://as.example","alice"]', + id="with-issuer-and-subject", + ), + ], +) +def test_authenticated_principal_is_the_tokens_client_issuer_subject_identity( + token: AccessToken, expected: str +) -> None: + """SDK-defined: the default binding composes (client_id, issuer, subject), degrading per component.""" + reset = auth_context_var.set(AuthenticatedUser(token)) try: - assert authenticated_principal(_bare_context()) == "client-123" + assert authenticated_principal(_bare_context()) == expected finally: auth_context_var.reset(reset) + + +def test_authenticated_principal_distinguishes_two_subjects_of_one_client() -> None: + """SDK-defined: two users of the same OAuth client are distinct principals when subjects are supplied.""" + alice = AccessToken(token="at-a", client_id="https://agent.example/client.json", scopes=[], subject="alice") + bob = AccessToken(token="at-b", client_id="https://agent.example/client.json", scopes=[], subject="bob") + principals: list[str | None] = [] + for token in (alice, bob): + reset = auth_context_var.set(AuthenticatedUser(token)) + try: + principals.append(authenticated_principal(_bare_context())) + finally: + auth_context_var.reset(reset) + assert principals[0] != principals[1] + + +def test_authenticated_principal_uses_the_same_components_as_session_ownership() -> None: + """SDK-defined: the binding and authorization_context derive from one principal_components source.""" + token = AccessToken( + token="at-1", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + assert authorization_context(AuthenticatedUser(token)) == { + "client_id": "client-123", + "issuer": "https://as.example", + "subject": "alice", + } + assert list(principal_components(token)) == ["client-123", "https://as.example", "alice"] diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 8f90036be..8dd517e50 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -181,7 +181,7 @@ async def call_tool( return CallToolResult(content=[TextContent(text="done")]) server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=server.name)) with anyio.fail_after(5): async with Client(server) as client: @@ -194,6 +194,8 @@ async def call_tool( assert isinstance(second, CallToolResult) assert seen == [plaintext] + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(first.request_state)) + assert claims["aud"] == "srv" async def test_a_resource_template_flow_seals_on_resources_read_and_restores_the_plaintext() -> None: @@ -505,7 +507,7 @@ async def call_tool( security = RequestStateSecurity(keys=[_KEY]) bound = make_server(RequestStateBoundary(security, default_audience="svc")) - unbound = make_server(RequestStateBoundary(security)) + unbound = make_server(RequestStateBoundary(security, default_audience=None)) with anyio.fail_after(5): async with Client(bound) as on_bound, Client(unbound) as on_unbound: @@ -669,7 +671,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam return CallToolResult(content=[TextContent(text="ran")]) server = Server("srv", on_call_tool=call_tool) - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: @@ -699,7 +701,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParam server = Server("srv", on_call_tool=call_tool) if install_boundary: - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) async with connected_runner(server) as (client, _): result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": None}) @@ -721,7 +723,7 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> server = Server("srv", on_list_tools=_list_tools) server.add_request_handler("example/mrtr", _CustomMethodParams, custom) - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) async with connected_runner(server) as (client, _): ok = await client.send_raw_request("example/mrtr", {"requestState": "CLIENT-SENT-VALUE"}) @@ -740,7 +742,7 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> server = Server("srv", on_list_tools=_list_tools) server.add_request_handler("example/mrtr", _CustomMethodParams, custom) - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) async with connected_runner(server) as (client, _): result = await client.send_raw_request("example/mrtr", {}) @@ -757,7 +759,7 @@ async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> server = Server("srv", on_list_tools=_list_tools) server.add_request_handler("example/mrtr", _CustomMethodParams, custom) - server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]))) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) async with connected_runner(server) as (client, _): result = await client.send_raw_request("example/mrtr", {}) @@ -926,7 +928,7 @@ async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( async def test_a_complete_result_crosses_the_boundary_untouched() -> None: """SDK-defined: a complete tools/call wire result passes the boundary as the identical object.""" - boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) complete: dict[str, Any] = {"resultType": "complete", "content": []} async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: @@ -970,7 +972,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: async def test_an_input_required_mapping_with_a_non_string_state_is_not_sealed() -> None: """SDK-defined: a non-string `requestState` in a wire mapping is not this module's mint; it crosses unchanged.""" - boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) malformed: dict[str, Any] = {"resultType": "input_required", "inputRequests": {}, "requestState": 7} async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: @@ -989,7 +991,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_notification_crosses_the_boundary_unharmed() -> None: """SDK-defined: the boundary is inert for notifications.""" - boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) forwarded: list[ServerRequestContext[Any, Any]] = [] async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: @@ -1011,7 +1013,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_non_mrtr_method_with_no_params_is_untouched() -> None: """SDK-defined: a non-carrier method with absent params passes the boundary inert.""" - boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) listing: dict[str, Any] = {"tools": [], "resultType": "complete"} async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: @@ -1033,7 +1035,7 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: async def test_a_short_circuited_input_required_model_is_sealed_via_the_model_path() -> None: """SDK-defined: a short-circuited `InputRequiredResult` model is sealed via the model path, on a copy.""" - boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None)) + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) interim = InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="model-plaintext") async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: @@ -1057,3 +1059,133 @@ async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(result.request_state)) assert (claims["m"], claims["t"], claims["s"]) == ("tools/call", "shortcut", "model-plaintext") assert interim.request_state == "model-plaintext" + + +# -- user-supplied code on the seal path fails closed ----------------------------------- + + +class _RaisingSealCodec: + """Codec whose seal always fails, standing in for a KMS outage in a custom codec.""" + + def seal(self, payload: bytes) -> str: + raise RuntimeError("kms unreachable at 10.0.0.7: wrapped-key-id-42") + + def unseal(self, token: str) -> bytes: + raise InvalidRequestState("never minted") + + +async def test_a_codec_that_raises_during_seal_yields_a_sanitized_internal_error() -> None: + """SDK-defined: a raising custom codec fails the round with a sanitized internal error, never its own text.""" + mcp, _ = _manual_server(RequestStateSecurity(codec=_RaisingSealCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + + +async def test_a_non_string_principal_fails_closed_when_sealing() -> None: + """SDK-defined: a bind_principal returning a non-string denies the round with the sanitized internal error.""" + + def numeric_user_id(ctx: ServerRequestContext[Any, Any]) -> str: + return cast("str", 12345) + + mcp, _ = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=numeric_user_id)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + + +async def test_a_non_string_principal_fails_closed_when_verifying() -> None: + """SDK-defined: a non-string principal on the verify side rejects with the frozen error, not a crash.""" + principal: list[Any] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = 12345 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert seen == [] + + +# -- lone surrogates: every encode on the state path is total ---------------------------- + + +async def test_lone_surrogate_arguments_are_digested_not_crashed() -> None: + """SDK-defined: a lone UTF-16 surrogate in an argument string digests like any other value.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "\ud800-prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "\udfff-prod"}, token) + second = await _retry(client, "deploy", {"env": "\ud800-prod"}, token) + + _assert_frozen_rejection(exc) # different args reject as a binding mismatch, not an internal error + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_lone_surrogate_handler_state_seals_and_restores() -> None: + """SDK-defined: handler-minted state containing a lone surrogate round-trips through the seal exactly.""" + plaintext = "awaiting-\ud800-confirm" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY]), state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +async def test_lone_surrogate_principal_binds_and_verifies() -> None: + """SDK-defined: a principal string containing a lone surrogate binds and verifies like any other.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: "tenant-\ud800")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- fractional clocks: the configured ttl is the effective ttl -------------------------- + + +async def test_a_fractional_mint_instant_keeps_the_full_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """SDK-defined: a token minted at a fractional instant lives the full configured ttl.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=0.5)) + clock = _Clock(_T0 + 0.9) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + clock.now = _T0 + 1.3 # 0.4s after mint, inside the 0.5s ttl + second = await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + 2.0 + late = await _first_round(client, "deploy", {"env": "prod"}) + clock.now = _T0 + 2.6 # 0.6s after mint, past the ttl + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, late) + + assert isinstance(second, CallToolResult) + _assert_frozen_rejection(exc) + assert seen == ["awaiting-confirm"] diff --git a/tests/server/test_request_state_gate.py b/tests/server/test_request_state_gate.py index 61b6aea18..806b8aa52 100644 --- a/tests/server/test_request_state_gate.py +++ b/tests/server/test_request_state_gate.py @@ -202,7 +202,7 @@ async def call_tool( server = Server("lowlevel", on_call_tool=call_tool) baseline = len(server.middleware) - server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral())) + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) assert len(server.middleware) == baseline + 1 @@ -231,3 +231,28 @@ def test_the_gate_fires_in_the_synchronous_registration_frame_not_at_first_reque mcp.tool(name="plain_tool")(_plain_tool) assert mcp._tool_manager.get_tool("plain_tool") is not None + + +# -- audience requires a server identity ------------------------------------------------ + + +@pytest.mark.parametrize("name", [None, ""], ids=["unnamed", "empty-string"]) +def test_an_unnamed_server_with_security_must_name_itself_or_set_an_audience(name: str | None) -> None: + """SDK-defined: `request_state_security=` without a real name raises; any falsy name + would stamp the shared placeholder as the token audience.""" + with pytest.raises(ValueError) as excinfo: + MCPServer(name, request_state_security=RequestStateSecurity.ephemeral()) + + assert str(excinfo.value) == snapshot("""\ +request_state_security is configured but this server has no name. Sealed +requestState carries the server name as an audience claim, so state minted by +another service that shares the same keys is rejected; unnamed servers would +all stamp the same placeholder and the check would mean nothing. Name the +server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).\ +""") + + +def test_a_named_server_or_an_explicit_audience_satisfies_the_audience_requirement() -> None: + """SDK-defined: naming the server or setting `RequestStateSecurity(audience=...)` both construct.""" + MCPServer("named", request_state_security=RequestStateSecurity.ephemeral()) + MCPServer(request_state_security=RequestStateSecurity.ephemeral(audience="svc")) From eaeaa318f0d454bd2f5c678013628973c404a02b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:22:47 +0000 Subject: [PATCH 6/8] Cover both directions of the failing-codec test The raising-codec stub's unseal was never executed, failing the coverage gate; drive it through a retry and assert the frozen rejection. Keep trailing assertions inside the client context so branch arcs resolve on every interpreter in the matrix. --- tests/server/test_request_state_boundary.py | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 8dd517e50..1b3fd5a23 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -1082,9 +1082,13 @@ async def test_a_codec_that_raises_during_seal_yields_a_sanitized_internal_error async with Client(mcp) as client: with pytest.raises(MCPError) as exc: await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) - - assert exc.value.error.code == INTERNAL_ERROR - assert exc.value.error.message == "Internal error" + # The unseal direction of the same broken codec still maps to the frozen rejection. + with pytest.raises(MCPError) as inbound: + await _retry(client, "deploy", {"env": "prod"}, "token-this-codec-never-minted") + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + _assert_frozen_rejection(inbound) + _assert_frozen_rejection(inbound) async def test_a_non_string_principal_fails_closed_when_sealing() -> None: @@ -1099,9 +1103,8 @@ def numeric_user_id(ctx: ServerRequestContext[Any, Any]) -> str: async with Client(mcp) as client: with pytest.raises(MCPError) as exc: await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) - - assert exc.value.error.code == INTERNAL_ERROR - assert exc.value.error.message == "Internal error" + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" async def test_a_non_string_principal_fails_closed_when_verifying() -> None: @@ -1115,9 +1118,8 @@ async def test_a_non_string_principal_fails_closed_when_verifying() -> None: principal[0] = 12345 with pytest.raises(MCPError) as exc: await _retry(client, "deploy", {"env": "prod"}, token) - - _assert_frozen_rejection(exc) - assert seen == [] + _assert_frozen_rejection(exc) + assert seen == [] # -- lone surrogates: every encode on the state path is total ---------------------------- @@ -1185,7 +1187,7 @@ async def test_a_fractional_mint_instant_keeps_the_full_ttl(monkeypatch: pytest. clock.now = _T0 + 2.6 # 0.6s after mint, past the ttl with pytest.raises(MCPError) as exc: await _retry(client, "deploy", {"env": "prod"}, late) + _assert_frozen_rejection(exc) assert isinstance(second, CallToolResult) - _assert_frozen_rejection(exc) assert seen == ["awaiting-confirm"] From e8d201fa4a71ee0b7d0a4447917c4a0fd7f63a9e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:29:02 +0000 Subject: [PATCH 7/8] Test the default principal binding across two subjects of one client The unit test pinned the principal string; this drives the boundary end to end under two bearer identities sharing a client_id: state minted for one subject is rejected for the other and restores only for the minter. --- tests/server/test_request_state_boundary.py | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 1b3fd5a23..9fc6f4b6e 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -28,6 +28,9 @@ import mcp.server.request_state as request_state_module from mcp import Client from mcp.server import MCPServer, Server, ServerRequestContext +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken from mcp.server.context import HandlerResult from mcp.server.mcpserver import Context from mcp.server.request_state import ( @@ -1191,3 +1194,62 @@ async def test_a_fractional_mint_instant_keeps_the_full_ttl(monkeypatch: pytest. assert isinstance(second, CallToolResult) assert seen == ["awaiting-confirm"] + + +async def test_default_principal_distinguishes_two_subjects_of_one_oauth_client() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5, cross-user reuse): with the + default binding, state sealed for one user of an OAuth client is rejected for another + user of the same client and restored only for the original subject.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience="svc") + seen: list[str | None] = [] + + async def mint(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return InputRequiredResult(input_requests={"confirm": _ask("PIN?")}, request_state="alice-secret") + + async def restore(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + assert ctx.params is not None + seen.append(ctx.params["requestState"]) + return CallToolResult(content=[TextContent(text="done")]) + + def request(token: str | None = None) -> ServerRequestContext[Any, Any]: + params: dict[str, Any] = {"name": "fetch_pin", "arguments": {}} + if token is not None: + params["requestState"] = token + return ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params=params, + ) + + def as_user(subject: str) -> AuthenticatedUser: + shared_client = "https://agent.example/client.json" + return AuthenticatedUser( + AccessToken(token=f"at-{subject}", client_id=shared_client, scopes=[], subject=subject) + ) + + reset = auth_context_var.set(as_user("alice")) + try: + sealed = await boundary(request(), mint) + finally: + auth_context_var.reset(reset) + assert isinstance(sealed, InputRequiredResult) + assert sealed.request_state is not None + + reset = auth_context_var.set(as_user("bob")) + try: + with pytest.raises(MCPError) as exc: + await boundary(request(sealed.request_state), restore) + finally: + auth_context_var.reset(reset) + _assert_frozen_rejection(exc) + assert seen == [] + + reset = auth_context_var.set(as_user("alice")) + try: + result = await boundary(request(sealed.request_state), restore) + finally: + auth_context_var.reset(reset) + assert isinstance(result, CallToolResult) + assert seen == ["alice-secret"] From 3e7e7f03ed97f3d4704b234a695bdfc569d631fc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:50:09 +0000 Subject: [PATCH 8/8] Seal requestState by default Every MCPServer now installs the request-state boundary under an ephemeral process-local key when no policy is supplied, so resolver state and hand-built state are sealed with zero configuration. The resolver registration gate and its teaching error are gone: nothing is required at construction anymore. One check remains, scoped to explicit configuration: a supplied policy on a server without a real name must set audience=, since shared keys are what make the audience claim load-bearing. Deployment guidance moves to the docs: multi-worker or load-balanced servers (and state that must survive restarts) share keys=[...] or bring a codec; the default key dies with the process and a cross-instance retry gets the frozen rejection and restarts the flow. Docs, tutorials, and stories drop the now-redundant ceremony; the migration entry is removed since the surface is new on this line. --- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 27 +- docs/migration.md | 14 -- docs/tutorial/dependencies.md | 3 +- docs/tutorial/elicitation.md | 3 +- docs_src/dependencies/tutorial001.py | 4 +- docs_src/dependencies/tutorial002.py | 4 +- docs_src/dependencies/tutorial003.py | 4 +- docs_src/elicitation/tutorial004.py | 3 +- examples/stories/README.md | 2 +- examples/stories/mrtr/README.md | 26 +- examples/stories/mrtr/server.py | 8 +- examples/stories/mrtr/server_lowlevel.py | 4 +- examples/stories/refund_desk/README.md | 7 +- examples/stories/refund_desk/server.py | 6 +- src/mcp/client/client.py | 2 +- src/mcp/server/mcpserver/server.py | 79 ++---- src/mcp/server/request_state.py | 15 +- tests/server/mcpserver/test_resolve.py | 30 +++ tests/server/mcpserver/test_server.py | 44 ++-- tests/server/test_request_state.py | 2 +- tests/server/test_request_state_boundary.py | 54 +++- tests/server/test_request_state_gate.py | 258 -------------------- 23 files changed, 177 insertions(+), 424 deletions(-) delete mode 100644 tests/server/test_request_state_gate.py diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 1d1923ea8..6568b76a5 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 523c2bade..62734b38f 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -40,7 +40,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT ``` * The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. -* Nothing extra is required to register this form: only `Resolve(...)` tools force a `request_state_security=` choice at construction. But if your function sets a `request_state`, what the client echoes back is client-supplied input; **[Protecting `requestState`](#protecting-requeststate)** below covers why you should configure protection anyway, and what you get when you do. +* A `request_state` you set is sealed before it crosses the wire and verified on the echo, like everything else on the server; **[Protecting `requestState`](#protecting-requeststate)** below covers what the seal gives you and when you need to configure keys. * An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. @@ -89,27 +89,24 @@ Drop to the underlying session, where `allow_input_required=True` hands you the Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. -The SDK requires a protection choice exactly where it authors the state itself: registering a `Resolve(...)` tool refuses to construct until you pass `request_state_security=`, because resolver state carries elicited answers the server will later trust. For state **you** build by returning `InputRequiredResult` from a tool, prompt, or resource template, nothing is required. But the echoed value is attacker-controlled input all the same, so you should configure protection there too: with `request_state_security=` set, your hand-built state is sealed and verified by the same machinery with zero code changes. You write plaintext and read plaintext. Without it, your state crosses the wire exactly as written, and the spec's integrity requirement is yours to satisfy: running unconfigured is a risk you accept, not a default the SDK chose for you. +`MCPServer` protects it by default. Every server seals outgoing `requestState` and verifies every echo — resolver state and hand-built state alike — under a key generated at process start. You configure nothing, write plaintext, and read plaintext; the wire only ever carries an opaque encrypted token. -There are two configurations: +The default key lives and dies with the process, which is the one thing you must know before deploying beyond a single process: ```python from mcp.server.mcpserver import MCPServer, RequestStateSecurity -# Multi-instance: one or more shared secret keys (>= 32 bytes each). +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) - -# Single process (stdio, one HTTP worker): a key generated at startup. -mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral()) ``` -* `keys=[...]` is the built-in encrypting codec under your secret(s). Required whenever a retry can reach a **different instance** (multi-worker or load-balanced HTTP), because every instance must be able to verify what any sibling minted. -* `.ephemeral()` generates the key at process start. State minted before a restart, or by another instance, is rejected and the client must start the flow over: right for a single process, wrong for a fleet. The resolver tutorials in these docs use it for that reason. +* **The default (no configuration)** suits a single process: stdio, or exactly one HTTP worker. A retry that lands on a different worker, a different instance behind a load balancer, or the same server after a restart is sealed under a key that process doesn't have — the client gets the frozen rejection below and must start the flow over. +* **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one. * For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. ### What the seal carries -With either built-in configuration, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: +Default or configured, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: * **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. * **The authenticated principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected. @@ -130,7 +127,7 @@ RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout. -Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, which is why `MCPServer` refuses `request_state_security=` on an unnamed server. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted. +Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.) ### Bring your own crypto @@ -150,15 +147,15 @@ Every inbound failure, whether tampered, expired, replayed against a different r {"code": -32602, "message": "Invalid or expired requestState"} ``` -One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Verification is a configured server's behavior: with `request_state_security=` set, every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. Without it, nothing is checked: inbound state reaches your handler exactly as the client sent it. +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters. ### Hand-built state -A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) never requires `request_state_security=`. Configure it anyway and your hand-built state is sealed and verified by the same machinery, with zero code changes: write plaintext, read plaintext, and every binding above applies. Don't, and the state crosses the wire exactly as written: whatever comes back is the client's word, and the spec's integrity requirement is yours to satisfy before you act on it. +A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies. The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. -The low-level `Server` is the no-batteries tier: nothing is required at construction and nothing is sealed until you append the boundary yourself. The one-line opt-in is shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. +The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. ## A 2026-07-28 result @@ -184,6 +181,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. -* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). +* `requestState` comes back as client-supplied input, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 351be2202..047626ee2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -423,20 +423,6 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. -### Tools with `Resolve(...)` parameters require `request_state_security=` - -`requestState` round-trips through the client, so what comes back is client-supplied input. `MCPServer` now requires a protection choice where the SDK authors that state itself: registering a tool that uses `Resolve(...)` parameters raises `ValueError` until you pass `request_state_security=`, because resolver state carries elicited answers the server later trusts. The one-line fix for a single-process server: - -```python -from mcp.server.mcpserver import MCPServer, RequestStateSecurity - -mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral()) -``` - -Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. A configured server must also be named (or pass `RequestStateSecurity(audience=...)`): the name becomes the sealed token's audience claim, so an unnamed server raises `ValueError` at construction. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate). - -On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote. Sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too. - ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index 7b239106f..8d6d91412 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -8,14 +8,13 @@ A tool's arguments come from the model. Some values never should: a price looked Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: -```python title="server.py" hl_lines="8 18-19 23" +```python title="server.py" hl_lines="18-19 23" --8<-- "docs_src/dependencies/tutorial001.py" ``` * `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument. * Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see. * The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble. -* `request_state_security=` is the one piece of ceremony. A tool with resolvers can pause mid-call to ask the user (that's later in this chapter), and resuming sends a token through the client, so the SDK makes you choose how that token is protected before it will build the server. `ephemeral()`, a key generated at process start, is the right choice for a single-process server like this one; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** has the full story. !!! info If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index 1a0c39cc5..7bd27a78a 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -85,14 +85,13 @@ The booking tool above weaves the question into its own body. When the question A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: -```python title="server.py" hl_lines="16 25-31 36-37" +```python title="server.py" hl_lines="24-30 35-36" --8<-- "docs_src/elicitation/tutorial004.py" ``` * `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. * `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. * The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. -* `request_state_security=` is new on this page's `MCPServer(...)`: on a 2026-07-28 connection the framework's question and its answer ride a resume token through the client, and a server with resolver tools must choose how that token is protected before it will construct. `ephemeral()` fits this single-process server; **[Protecting `requestState`](../advanced/multi-round-trip.md#protecting-requeststate)** explains the choices. Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. diff --git a/docs_src/dependencies/tutorial001.py b/docs_src/dependencies/tutorial001.py index 649271fb4..182b54414 100644 --- a/docs_src/dependencies/tutorial001.py +++ b/docs_src/dependencies/tutorial001.py @@ -3,9 +3,9 @@ from pydantic import BaseModel from mcp.server import MCPServer -from mcp.server.mcpserver import RequestStateSecurity, Resolve +from mcp.server.mcpserver import Resolve -mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) +mcp = MCPServer("Bookshop") INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/dependencies/tutorial002.py b/docs_src/dependencies/tutorial002.py index a46f223cd..3f24e2ceb 100644 --- a/docs_src/dependencies/tutorial002.py +++ b/docs_src/dependencies/tutorial002.py @@ -3,9 +3,9 @@ from pydantic import BaseModel from mcp.server import MCPServer -from mcp.server.mcpserver import RequestStateSecurity, Resolve +from mcp.server.mcpserver import Resolve -mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) +mcp = MCPServer("Bookshop") INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/dependencies/tutorial003.py b/docs_src/dependencies/tutorial003.py index 37245877a..51252668e 100644 --- a/docs_src/dependencies/tutorial003.py +++ b/docs_src/dependencies/tutorial003.py @@ -3,9 +3,9 @@ from pydantic import BaseModel, Field from mcp.server import MCPServer -from mcp.server.mcpserver import Elicit, RequestStateSecurity, Resolve +from mcp.server.mcpserver import Elicit, Resolve -mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral()) +mcp = MCPServer("Bookshop") INVENTORY = {"Dune": 7, "Neuromancer": 0} diff --git a/docs_src/elicitation/tutorial004.py b/docs_src/elicitation/tutorial004.py index 3feacc248..1edec06cf 100644 --- a/docs_src/elicitation/tutorial004.py +++ b/docs_src/elicitation/tutorial004.py @@ -9,11 +9,10 @@ DeclinedElicitation, Elicit, ElicitationResult, - RequestStateSecurity, Resolve, ) -mcp = MCPServer("Files", request_state_security=RequestStateSecurity.ephemeral()) +mcp = MCPServer("Files") _FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]} diff --git a/examples/stories/README.md b/examples/stories/README.md index 7b71c1a63..79d714311 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -128,7 +128,7 @@ opens with a banner saying what replaces it. | [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current | | **— feature stories —** | | | | [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | -| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and `RequestStateSecurity` sealing `requestState` (a tampered echo gets one frozen error) | current | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and the default `requestState` sealing (a tampered echo gets one frozen error) | current | | [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | | [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | | [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 3da3e6e43..870db7d29 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -8,10 +8,10 @@ original `tools/call` carrying `inputResponses` and the echoed `requestState`. The story shows both the `Client` auto-loop (one `await call_tool`, callbacks fired transparently) and a manual `client.session` loop (the persistable form). Because `requestState` round-trips through the client, it also shows -the security surface that protects it: the server is constructed with -`request_state_security=RequestStateSecurity.ephemeral()`, handlers keep -writing plaintext state, and the SDK seals it at the wire boundary. The manual -loop tampers with the sealed token to show what a forged echo gets back. +the security surface that protects it: `MCPServer` seals state by default +under a process-local key, handlers keep writing plaintext, and the wire only +ever carries an opaque token. The manual loop tampers with the sealed token to +show what a forged echo gets back. ## Run it @@ -25,15 +25,11 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## What to look at -- `server.py` `build_server`: the whole security opt-in is the single - constructor argument `request_state_security=RequestStateSecurity.ephemeral()`. - Opting in is this server's choice, since only tools with `Resolve(...)` - parameters are required to configure protection; a hand-built flow like - `deploy` would otherwise send its state across the wire as plaintext. - `ephemeral()` generates a key at process start, which is right for a +- `server.py` `build_server`: no security configuration at all. The default + seals under a key generated at process start, which is right for a single-process server like this one; a fleet (multi-worker or load-balanced) - shares keys with `RequestStateSecurity(keys=[...])` so any instance can - verify state another minted. + shares keys with `request_state_security=RequestStateSecurity(keys=[...])` + so any instance can verify state another minted. - `server.py` `deploy`: handlers stay plaintext. The first round returns `InputRequiredResult(input_requests={...}, request_state="awaiting-confirm")` and the retry asserts @@ -54,8 +50,8 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel The specific reason (tampered tag, expiry, wrong request, wrong principal) appears only in the server's log, never on the wire. The untampered token then completes the round normally. -- `server_lowlevel.py`: the lowlevel tier has no construction-time - requirement; the same enforcement is one appended middleware: +- `server_lowlevel.py`: the lowlevel tier doesn't seal by default; the same + enforcement is one appended middleware: `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name))`. @@ -64,7 +60,7 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel - **Loop bound.** The auto-loop gives up after `input_required_max_rounds` (default 10) with `InputRequiredRoundsExceededError`; raise it on the `Client` ctor or drop to the manual loop. -- **`ephemeral()` dies with the process.** The key is generated at startup and +- **The default key dies with the process.** It is generated at startup and held only in memory, so a server restart (or a retry landing on a different instance) invalidates in-flight rounds: the client gets the same frozen rejection and must start the flow over. Use diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py index 955f0c549..8155b90f4 100644 --- a/examples/stories/mrtr/server.py +++ b/examples/stories/mrtr/server.py @@ -2,7 +2,7 @@ from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult -from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity +from mcp.server.mcpserver import Context, MCPServer from stories._hosting import run_server_from_args CONFIRM_SCHEMA: ElicitRequestedSchema = { @@ -13,9 +13,9 @@ def build_server() -> MCPServer: - # requestState round-trips through the client, so minting one requires a protection - # policy. ephemeral() suits single-process servers; fleets share keys=[...]. - mcp = MCPServer("mrtr-example", request_state_security=RequestStateSecurity.ephemeral()) + # requestState is sealed by default under a process-local key, which suits this + # single-process server; fleets share keys=[...] so any instance can verify. + mcp = MCPServer("mrtr-example") @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py index 57b2f3993..6f3f489d8 100644 --- a/examples/stories/mrtr/server_lowlevel.py +++ b/examples/stories/mrtr/server_lowlevel.py @@ -57,8 +57,8 @@ async def call_tool( return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) - # Lowlevel opt-in: append the same boundary middleware MCPServer installs from - # request_state_security=; the server name becomes the token audience. + # Lowlevel opt-in: append the same boundary middleware MCPServer installs by + # default; the server name becomes the token audience. server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) return server diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 70b3d9030..f10363698 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -29,10 +29,9 @@ uv run python -m stories.refund_desk.client --http - `server.py` `refund_order` — the signature is the whole story: `order_id` and `reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers and never reach the input schema. `client.py` asserts `properties` and - `required` are exactly `{order_id, reason}`. The server is constructed with - `request_state_security=RequestStateSecurity.ephemeral()` because at 2026 the - resolver's elicited answers ride between rounds inside a sealed - `requestState`; see `mrtr/` for the full security walk-through. + `required` are exactly `{order_id, reason}`. At 2026 the resolver's elicited + answers ride between rounds inside a `requestState` the SDK seals by default; + see `mrtr/` for the full security walk-through. - `server.py` `refund_scope` — the no-round-trip fast path: a one-line order returns `Scope(full=True)` directly; only a multi-line order returns `Elicit(...)`. The ORD-7001 call completes with zero elicitations. diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py index 0c9be4da2..a263b9385 100644 --- a/examples/stories/refund_desk/server.py +++ b/examples/stories/refund_desk/server.py @@ -11,7 +11,6 @@ Elicit, ElicitationResult, MCPServer, - RequestStateSecurity, Resolve, ) from mcp.server.mcpserver.exceptions import ToolError @@ -104,8 +103,9 @@ def ask_restock( def build_server() -> MCPServer: - # Resolver tools refuse to register without requestState protection; see mrtr/ for the full story. - mcp = MCPServer("refund-desk", request_state_security=RequestStateSecurity.ephemeral()) + # Elicited answers ride between rounds in a requestState the SDK seals by default; + # see mrtr/ for the full security walk-through. + mcp = MCPServer("refund-desk") @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") def refund_order( diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index de482ce50..c2db891ca 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -611,7 +611,7 @@ async def call_tool( persist `request_state` across process restarts — use `client.session.call_tool(..., allow_input_required=True)`. Persisted state is still subject to the server's TTL, request binding, and key - lifetime; an `ephemeral()` server rejects it after a restart. + lifetime; a server on the default process-local key rejects it after a restart. Args: name: The name of the tool to call. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 65f3cb24f..3750429cd 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -73,7 +73,6 @@ from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError from mcp.server.mcpserver.prompts import Prompt, PromptManager -from mcp.server.mcpserver.resolve import find_resolved_parameters from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, FunctionResource, @@ -135,6 +134,15 @@ class Settings(BaseSettings, Generic[LifespanResultT]): auth: AuthSettings | None +_MISSING_AUDIENCE = ( + "request_state_security is configured but this server has no name. Sealed\n" + "requestState carries the server name as an audience claim, so state minted by\n" + "another service that shares the same keys is rejected; unnamed servers would\n" + "all stamp the same placeholder and the check would mean nothing. Name the\n" + 'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).' +) + + def lifespan_wrapper( app: MCPServer[LifespanResultT], lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], @@ -147,42 +155,6 @@ async def wrap(_: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]: return wrap -def _format_missing_security(owner: str) -> str: - """The teaching error for a resolver tool registered without request-state security.""" - return ( - f"{owner} uses Resolve(...) parameters, so this server mints a\n" - "requestState carrying elicited answers that round-trips through the client. The\n" - "MCP spec requires that state to be integrity-protected, and rejected when\n" - "verification fails, whenever it can influence authorization, resource access,\n" - "or business logic. Configure protection:\n" - "\n" - " MCPServer(..., request_state_security=RequestStateSecurity(keys=[key]))\n" - " One or more shared secret keys (>= 32 bytes each). Required when a retry\n" - " can reach a different instance (multi-worker or load-balanced HTTP).\n" - " keys[0] seals, every key verifies; rotation is\n" - " [old, new] -> [new, old] -> [new], each phase fully rolled out first.\n" - "\n" - " MCPServer(..., request_state_security=RequestStateSecurity.ephemeral())\n" - " A key generated at process start. Single-process deployments only\n" - " (stdio, one HTTP worker): state minted before a restart, or by another\n" - " instance, is rejected and the client must restart the flow.\n" - "\n" - "For your own crypto (a KMS, an existing token service), pass\n" - "RequestStateSecurity(codec=...).\n" - "\n" - "Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr" - ) - - -_MISSING_AUDIENCE = ( - "request_state_security is configured but this server has no name. Sealed\n" - "requestState carries the server name as an audience claim, so state minted by\n" - "another service that shares the same keys is rejected; unnamed servers would\n" - "all stamp the same placeholder and the check would mean nothing. Name the\n" - 'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).' -) - - class MCPServer(Generic[LifespanResultT]): def __init__( self, @@ -212,7 +184,6 @@ def __init__( cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, ): self._resource_security = resource_security - self._request_state_security = request_state_security self.settings = Settings( debug=debug, log_level=log_level, @@ -252,16 +223,15 @@ def __init__( ) # Ordering: inside OpenTelemetry (spans record the sealed wire form), # outside extension interceptors (extensions see plaintext). - if request_state_security is not None: - # `not name` mirrors the `name or "mcp-server"` fallback: any falsy name gets the placeholder. + if request_state_security is None: + security = RequestStateSecurity.ephemeral() + else: + # A supplied policy usually means shared keys, where the audience claim is + # what separates services; an unnamed server would stamp the placeholder. if not name and request_state_security.audience is None: raise ValueError(_MISSING_AUDIENCE) - self._lowlevel_server.middleware.append( - RequestStateBoundary(request_state_security, default_audience=self.name) - ) - # Constructor-supplied Tool objects bypass add_tool, so gate them here. - for tool in self._tool_manager.list_tools(): - self._check_resolver_protection(tool, owner=f"Tool {tool.name!r}") + security = request_state_security + self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name)) # Validate auth configuration if self.settings.auth is not None: if auth_server_provider and token_verifier: # pragma: no cover @@ -578,19 +548,6 @@ async def read_resource( # If an exception happens when reading the resource, we should not leak the exception to the client. raise ResourceError(f"Error reading resource {uri}") from exc - def _check_resolver_protection(self, subject: Tool | Callable[..., Any], *, owner: str) -> None: - """Refuse a resolver-tool registration when the server has no request-state security. - - The spec requires integrity protection for resolver state (SDK-authored - elicited answers). Manual `InputRequiredResult` flows stay ungated: - protection for their user-authored state is only recommended. - """ - if self._request_state_security is not None: - return - resolved = subject.resolved_params if isinstance(subject, Tool) else find_resolved_parameters(subject) - if resolved: - raise ValueError(_format_missing_security(owner)) - def add_tool( self, fn: Callable[..., Any], @@ -619,11 +576,7 @@ def add_tool( - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool - - Raises: - ValueError: If the tool uses `Resolve(...)` parameters without `request_state_security` configured. """ - self._check_resolver_protection(fn, owner=f"Tool {name or fn.__name__!r}") self._tool_manager.add_tool( fn, name=name, diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py index 27cd467e3..ad1abe8c3 100644 --- a/src/mcp/server/request_state.py +++ b/src/mcp/server/request_state.py @@ -140,9 +140,11 @@ def __init__( def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity: """Protection under a key generated now and held only by this process. - Suits single-process deployments (stdio, one HTTP worker): state minted - before a restart or by another worker is rejected. Multi-instance - deployments must share a key via `keys=[...]`. + This is the policy `MCPServer` installs when `request_state_security=` + is omitted; call it yourself on the lowlevel tier or to set `ttl`/ + `audience`. Suits single-process deployments (stdio, one HTTP worker): + state minted before a restart or by another worker is rejected. + Multi-instance deployments must share a key via `keys=[...]`. """ return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) @@ -338,9 +340,10 @@ class RequestStateBoundary: `default_audience` seeds the audience claim when the policy sets none, and must be stated explicitly: it is the service identity that stops state minted by another service sharing the same keys. `MCPServer` installs this - middleware with its server name when `request_state_security=` is supplied; - lowlevel `Server` users append one to `server.middleware`, passing their - server's name (or `None` to deliberately leave tokens audience-free). + middleware with its server name by default (under an ephemeral policy + unless `request_state_security=` supplies one); lowlevel `Server` users + append one to `server.middleware`, passing their server's name (or `None` + to deliberately leave tokens audience-free). """ def __init__(self, security: RequestStateSecurity, *, default_audience: str | None) -> None: diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index d55c7d9bb..c28f12481 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -2335,3 +2335,33 @@ async def act( assert isinstance(final, CallToolResult) assert isinstance(final.content[0], TextContent) assert final.content[0].text == "oc\ud800t:True" + + +@pytest.mark.anyio +async def test_resolver_elicitation_seals_and_completes_on_a_fully_default_server(): + # The headline default-posture invariant: a resolver tool on a bare MCPServer() - + # no name, no security configuration - mints sealed state and completes the round. + mcp = MCPServer() + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit("Go?", Confirm) + + @mcp.tool() + async def act(go: Annotated[Confirm, Resolve(ask)]) -> str: + return f"went:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state.startswith("v1.") + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "went:True" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 8fb36267c..2ae9d5ff7 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1866,8 +1866,8 @@ def get_user(user_id: str) -> str: assert exc_info.value.error.data == {"uri": "resource://users/999"} -async def test_tool_returning_input_required_result_reaches_client_unchanged(): - # Unconfigured server: the wire carries the handler's requestState as plaintext. +async def test_tool_returning_input_required_result_reaches_client_sealed(): + # Default posture: the wire carries an opaque sealed token, never the handler's plaintext. mcp = MCPServer() @mcp.tool() @@ -1879,7 +1879,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: result = await client.session.call_tool("ask", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["roots"].method == "roots/list" @@ -1928,6 +1928,13 @@ async def greet(ctx: Context) -> str | InputRequiredResult: assert block.text == "Hello, Alice! (state=r1)" +def _assert_sealed(state: str | None, plaintext: str) -> None: + """The wire form is an opaque sealed token, never the handler's plaintext.""" + assert state is not None + assert state != plaintext + assert state.startswith("v1.") + + def _ask_who() -> ElicitRequest: return ElicitRequest( params=ElicitRequestFormParams( @@ -1941,9 +1948,9 @@ def _ask_who() -> ElicitRequest: ) -async def test_prompt_returning_input_required_result_reaches_client_unchanged(): - """A prompt function may return an InputRequiredResult and the pipeline passes it - through to the client (spec-mandated: SEP-2322 allows it on prompts/get).""" +async def test_prompt_returning_input_required_result_reaches_client_sealed(): + """A prompt function may return an InputRequiredResult and the pipeline delivers it + to the client with the state sealed (spec-mandated: SEP-2322 allows it on prompts/get).""" mcp = MCPServer() @mcp.prompt() @@ -1955,7 +1962,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: result = await client.session.get_prompt("briefing", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["who"].method == "elicitation/create" @@ -2024,9 +2031,9 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: assert exc.value.error.message == "Handler returned an invalid result" -async def test_resource_template_returning_input_required_result_reaches_client_unchanged(): +async def test_resource_template_returning_input_required_result_reaches_client_sealed(): """A resource template function may return an InputRequiredResult and the pipeline - passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read).""" + delivers it with the state sealed (spec-mandated: SEP-2322 allows it on resources/read).""" mcp = MCPServer() @mcp.resource("ask://{topic}") @@ -2038,7 +2045,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: result = await client.session.read_resource("ask://databases", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["who"].method == "elicitation/create" @@ -2110,10 +2117,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template(): """ctx.read_resource never participates in the multi-round-trip flow, so the nested template must not see the outer request's input_responses/request_state — a colliding - key would otherwise consume an answer meant for the outer handler's own question. - - Unconfigured server: the client-built plaintext probe must reach the outer - context as-sent; the subject is isolation, not the wire seal.""" + key would otherwise consume an answer meant for the outer handler's own question.""" mcp = MCPServer() seen_responses: list[InputResponses | None] = [] seen_state: list[str | None] = [] @@ -2125,22 +2129,26 @@ async def ask(topic: str, ctx: Context) -> str: return f"{topic} content" @mcp.tool() - async def outer(ctx: Context) -> str: + async def outer(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="outer-state") contents = list(await ctx.read_resource("ask://databases")) assert isinstance(contents[0].content, str) - return contents[0].content + return f"{contents[0].content} (state={ctx.request_state})" with anyio.fail_after(5): async with Client(mcp, mode="2026-07-28") as client: + r1 = await client.session.call_tool("outer", allow_input_required=True) + assert isinstance(r1, InputRequiredResult) result = await client.session.call_tool( "outer", input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, - request_state="outer-state", + request_state=r1.request_state, ) assert isinstance(result, CallToolResult) block = result.content[0] assert isinstance(block, TextContent) - assert block.text == "databases content" + assert block.text == "databases content (state=outer-state)" assert seen_responses == [None] assert seen_state == [None] diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py index 473907b54..590c046e9 100644 --- a/tests/server/test_request_state.py +++ b/tests/server/test_request_state.py @@ -340,7 +340,7 @@ def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: - """SDK-defined: a policy must name its codec; opting out means omitting `request_state_security=` entirely.""" + """SDK-defined: a policy must name its codec; an empty policy is a mistake, not a posture.""" with pytest.raises(ValueError) as exc: RequestStateSecurity() assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index 9fc6f4b6e..ed4e5b066 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -33,6 +33,7 @@ from mcp.server.auth.provider import AccessToken from mcp.server.context import HandlerResult from mcp.server.mcpserver import Context +from mcp.server.mcpserver.server import _MISSING_AUDIENCE from mcp.server.request_state import ( AESGCMRequestStateCodec, InvalidRequestState, @@ -115,7 +116,10 @@ def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: def _manual_server( security: RequestStateSecurity | None, *, state: str = "awaiting-confirm", name: str = "manual" ) -> tuple[MCPServer, list[str | None]]: - """MCPServer with one manual MRTR tool: round 1 asks, the retry records the echoed `ctx.request_state`.""" + """MCPServer with one manual MRTR tool: round 1 asks, the retry records the echoed `ctx.request_state`. + + `security=None` exercises the default posture (process-local ephemeral sealing), not plaintext. + """ seen: list[str | None] = [] mcp = MCPServer(name, request_state_security=security) @@ -616,11 +620,11 @@ async def wizard(ctx: Context) -> str | InputRequiredResult: assert (claims_one["iat"], claims_two["iat"]) == (int(_T0), int(_T0) + 5) -# -- unconfigured servers: plaintext passthrough (the unprotected posture) ------------- +# -- the default posture: every MCPServer seals under an ephemeral policy --------------- -async def test_an_unconfigured_mcpserver_passes_request_state_through_verbatim() -> None: - """SDK-defined: an MCPServer without `request_state_security=` passes `requestState` through verbatim.""" +async def test_an_mcpserver_seals_request_state_by_default() -> None: + """SDK-defined: with no `request_state_security=`, an MCPServer seals under a process-local key.""" plaintext = "plain-wizard-state" mcp, seen = _manual_server(None, state=plaintext) @@ -628,13 +632,36 @@ async def test_an_unconfigured_mcpserver_passes_request_state_through_verbatim() async with Client(mcp) as client: first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) assert isinstance(first, InputRequiredResult) - assert first.request_state == plaintext - second = await _retry(client, "deploy", {"env": "prod"}, plaintext) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + with pytest.raises(MCPError) as fabricated: + await _retry(client, "deploy", {"env": "prod"}, plaintext) + second = await _retry(client, "deploy", {"env": "prod"}, first.request_state) + _assert_frozen_rejection(fabricated) assert isinstance(second, CallToolResult) assert seen == [plaintext] +async def test_the_default_key_is_per_instance_so_servers_never_cross_accept() -> None: + """SDK-defined: each default MCPServer mints its own ephemeral key; another instance rejects its state.""" + one, seen_one = _manual_server(None) + two, seen_two = _manual_server(None) + + with anyio.fail_after(5): + async with Client(one) as on_one, Client(two) as on_two: + token = await _first_round(on_one, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(on_two, "deploy", {"env": "prod"}, token) + second = await _retry(on_one, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen_one == ["awaiting-confirm"] + assert seen_two == [] + + async def test_a_boundary_free_lowlevel_server_passes_request_state_through_verbatim() -> None: """SDK-defined: without a boundary in `Server.middleware`, `requestState` crosses as the handler's plaintext.""" plaintext = "lowlevel-plain-round-1" @@ -1253,3 +1280,18 @@ def as_user(subject: str) -> AuthenticatedUser: auth_context_var.reset(reset) assert isinstance(result, CallToolResult) assert seen == ["alice-secret"] + + +@pytest.mark.parametrize("name", [None, ""], ids=["unnamed", "empty-string"]) +def test_a_shared_key_policy_without_a_real_name_must_set_an_audience(name: str | None) -> None: + """SDK-defined: explicit keys usually mean a fleet, where the audience claim is what + separates services; without a real name every server would stamp the same placeholder.""" + with pytest.raises(ValueError) as excinfo: + MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY])) + assert str(excinfo.value) == _MISSING_AUDIENCE + + # Every neighboring posture constructs: the default needs no name, and a real + # name or an explicit audience satisfies a shared-key policy. + MCPServer(name) + MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY], audience="svc")) + MCPServer("named", request_state_security=RequestStateSecurity(keys=[_KEY])) diff --git a/tests/server/test_request_state_gate.py b/tests/server/test_request_state_gate.py deleted file mode 100644 index 806b8aa52..000000000 --- a/tests/server/test_request_state_gate.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Startup gate for `request_state_security=` on the MCP-server registration funnels. - -Every test is synchronous registration-time behavior: no Client, no connection, -no event loop. The gate is resolver-only: a `Resolve(...)` tool's requestState -carries elicited answers, which the spec requires to be integrity-protected -(mrtr server requirements 4-5). Manual `InputRequiredResult` surfaces are not -gated; their author-written state passes through an unconfigured server as -plaintext (pinned by the boundary tests). -""" - -from typing import Annotated, Any - -import pytest -from inline_snapshot import snapshot -from mcp_types import CallToolRequestParams, CallToolResult, InputRequiredResult - -from mcp.server import MCPServer, Server, ServerRequestContext -from mcp.server.extension import Extension, ToolBinding -from mcp.server.mcpserver import Context, Resolve -from mcp.server.mcpserver.prompts import Prompt -from mcp.server.mcpserver.tools import Tool -from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity - -# Registration fixtures: only their signatures are inspected and none is ever -# called, so each body is a bare `...` (nothing for coverage to miss). - - -# Resolver for `Resolve(...)` markers: -async def _provide_login(ctx: Context) -> str: ... - - -# Resolver-driven tool (the only gated capability): -async def _deploy(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str: ... - - -# Manual-MRTR tool, prompt, and resource template (not gated): -async def _confirm_deploy(target: str) -> str | InputRequiredResult: ... - - -async def _briefing(topic: str) -> str | InputRequiredResult: ... - - -async def _record(id: str) -> str | InputRequiredResult: ... - - -# MRTR-free tool, prompt, static resource, and resource template: -async def _plain_tool(x: int) -> str: ... - - -async def _plain_prompt() -> str: ... - - -async def _plain_static() -> str: ... - - -async def _plain_template(id: str) -> str: ... - - -def test_resolver_tool_without_security_is_rejected_at_the_decorator_call() -> None: - """SDK-defined: a `Resolve(...)` tool on a server without `request_state_security=` - is rejected at the `@mcp.tool()` call with the full teaching text.""" - mcp = MCPServer("gate") - - with pytest.raises(ValueError) as excinfo: - mcp.tool(name="deploy")(_deploy) - - assert str(excinfo.value) == snapshot("""\ -Tool 'deploy' uses Resolve(...) parameters, so this server mints a -requestState carrying elicited answers that round-trips through the client. The -MCP spec requires that state to be integrity-protected, and rejected when -verification fails, whenever it can influence authorization, resource access, -or business logic. Configure protection: - - MCPServer(..., request_state_security=RequestStateSecurity(keys=[key])) - One or more shared secret keys (>= 32 bytes each). Required when a retry - can reach a different instance (multi-worker or load-balanced HTTP). - keys[0] seals, every key verifies; rotation is - [old, new] -> [new, old] -> [new], each phase fully rolled out first. - - MCPServer(..., request_state_security=RequestStateSecurity.ephemeral()) - A key generated at process start. Single-process deployments only - (stdio, one HTTP worker): state minted before a restart, or by another - instance, is rejected and the client must restart the flow. - -For your own crypto (a KMS, an existing token service), pass -RequestStateSecurity(codec=...). - -Spec: https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr\ -""") - - -def test_constructor_supplied_resolver_tool_bypasses_add_tool_but_is_still_rejected() -> None: - """SDK-defined: `MCPServer(tools=[...])` bypasses `add_tool`, so `__init__` re-scans and rejects, naming it.""" - tool = Tool.from_function(_deploy, name="deploy") - - with pytest.raises(ValueError) as excinfo: - MCPServer("gate", tools=[tool]) - - assert "deploy" in str(excinfo.value) - - -def test_constructor_scan_trusts_the_tools_stored_resolver_authority() -> None: - """SDK-defined: the constructor scan judges a hand-built Tool by its stored `resolved_params`, not its fn.""" - tool = Tool.from_function(_deploy, name="deploy").model_copy(update={"fn": _plain_tool}) - - with pytest.raises(ValueError) as excinfo: - MCPServer("gate", tools=[tool]) - - assert "uses Resolve(...) parameters" in str(excinfo.value) - - -def test_constructor_scan_does_not_defer_a_hand_built_combo_tool() -> None: - """SDK-defined: a hand-built Tool whose `resolved_params` and fn disagree is judged by the stored authority.""" - tool = Tool.from_function(_deploy, name="combo").model_copy(update={"fn": _confirm_deploy}) - - with pytest.raises(ValueError) as excinfo: - MCPServer("gate", tools=[tool]) - - assert "uses Resolve(...) parameters" in str(excinfo.value) - - -def test_decorator_combo_fn_on_an_unconfigured_server_raises_the_resolver_gate_error() -> None: - """SDK-defined: the `add_tool` gate runs before `Tool.from_function`, so the combo fn raises the resolver error.""" - mcp = MCPServer("gate") - - async def combo(target: str, login: Annotated[str, Resolve(_provide_login)]) -> str | InputRequiredResult: ... - - with pytest.raises(ValueError) as excinfo: - mcp.tool()(combo) - - assert "uses Resolve(...) parameters" in str(excinfo.value) - - -def test_declared_manual_surfaces_register_cleanly_on_an_unconfigured_server() -> None: - """SDK-defined: declared manual surfaces are not gated; every funnel registers them on an unconfigured server.""" - mcp = MCPServer("gate", tools=[Tool.from_function(_confirm_deploy, name="ctor_confirm_deploy")]) - - mcp.tool(name="confirm_deploy")(_confirm_deploy) - mcp.prompt(name="briefing")(_briefing) - mcp.add_prompt(Prompt.from_function(_briefing, name="briefing_via_add")) - mcp.resource("data://{id}")(_record) - - assert mcp._tool_manager.get_tool("ctor_confirm_deploy") is not None - assert mcp._tool_manager.get_tool("confirm_deploy") is not None - assert mcp._prompt_manager.get_prompt("briefing") is not None - assert mcp._prompt_manager.get_prompt("briefing_via_add") is not None - assert [t.uri_template for t in mcp._resource_manager.list_templates()] == ["data://{id}"] - - -def test_every_mrtr_surface_registers_cleanly_once_security_is_configured() -> None: - """SDK-defined: with `request_state_security=` supplied, every MRTR surface registers via every funnel.""" - mcp = MCPServer( - "gate", - request_state_security=RequestStateSecurity.ephemeral(), - tools=[Tool.from_function(_deploy, name="deploy")], - ) - mcp.tool(name="confirm_deploy")(_confirm_deploy) - mcp.prompt(name="briefing")(_briefing) - mcp.add_prompt(Prompt.from_function(_briefing, name="briefing_via_add")) - mcp.resource("data://{id}")(_record) - - assert mcp._tool_manager.get_tool("deploy") is not None - assert mcp._tool_manager.get_tool("confirm_deploy") is not None - assert mcp._prompt_manager.get_prompt("briefing") is not None - assert mcp._prompt_manager.get_prompt("briefing_via_add") is not None - assert [t.uri_template for t in mcp._resource_manager.list_templates()] == ["data://{id}"] - - -def test_mrtr_free_registrations_need_no_security_configuration() -> None: - """SDK-defined: the gate keys on `Resolve(...)` usage; MRTR-free registrations work exactly as before.""" - mcp = MCPServer("gate", tools=[Tool.from_function(_plain_tool, name="ctor_plain_tool")]) - - mcp.tool(name="plain_tool")(_plain_tool) - mcp.prompt(name="plain_prompt")(_plain_prompt) - mcp.resource("data://static")(_plain_static) - mcp.resource("plain://{id}")(_plain_template) - - assert mcp._tool_manager.get_tool("ctor_plain_tool") is not None - assert mcp._tool_manager.get_tool("plain_tool") is not None - assert mcp._prompt_manager.get_prompt("plain_prompt") is not None - assert len(mcp._resource_manager.list_resources()) == 1 - assert len(mcp._resource_manager.list_templates()) == 1 - - -def test_security_with_zero_mrtr_registrations_is_legal_and_inert() -> None: - """SDK-defined: `request_state_security=` with no MRTR-capable registration is legal and inert.""" - mcp = MCPServer("gate", request_state_security=RequestStateSecurity.ephemeral()) - - mcp.tool(name="plain_tool")(_plain_tool) - - assert mcp._tool_manager.get_tool("plain_tool") is not None - - -def test_lowlevel_server_has_no_gate_and_takes_the_boundary_as_ordinary_middleware() -> None: - """SDK-defined: the lowlevel `Server` has no gate; protection is an explicit `RequestStateBoundary` middleware.""" - - # Handler fixture: lowlevel registration neither inspects nor runs it here. - async def call_tool( - ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams - ) -> CallToolResult | InputRequiredResult: ... - - server = Server("lowlevel", on_call_tool=call_tool) - baseline = len(server.middleware) - - server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) - - assert len(server.middleware) == baseline + 1 - - -def test_extension_contributed_resolver_tool_is_gated_through_add_tool() -> None: - """SDK-defined: extension tools register through `MCPServer.add_tool`, so the gate covers them.""" - - class ResolverExt(Extension): - identifier = "com.example/resolver" - - def tools(self) -> list[ToolBinding]: - return [ToolBinding(fn=_deploy, kwargs={"name": "deploy"})] - - with pytest.raises(ValueError) as excinfo: - MCPServer("gate", extensions=[ResolverExt()]) - - assert "deploy" in str(excinfo.value) - - -def test_the_gate_fires_in_the_synchronous_registration_frame_not_at_first_request() -> None: - """SDK-defined: rejection happens in the registration frame and leaves the server usable afterward.""" - mcp = MCPServer("gate") - - with pytest.raises(ValueError): - mcp.tool(name="deploy")(_deploy) - - mcp.tool(name="plain_tool")(_plain_tool) - assert mcp._tool_manager.get_tool("plain_tool") is not None - - -# -- audience requires a server identity ------------------------------------------------ - - -@pytest.mark.parametrize("name", [None, ""], ids=["unnamed", "empty-string"]) -def test_an_unnamed_server_with_security_must_name_itself_or_set_an_audience(name: str | None) -> None: - """SDK-defined: `request_state_security=` without a real name raises; any falsy name - would stamp the shared placeholder as the token audience.""" - with pytest.raises(ValueError) as excinfo: - MCPServer(name, request_state_security=RequestStateSecurity.ephemeral()) - - assert str(excinfo.value) == snapshot("""\ -request_state_security is configured but this server has no name. Sealed -requestState carries the server name as an audience claim, so state minted by -another service that shares the same keys is rejected; unnamed servers would -all stamp the same placeholder and the check would mean nothing. Name the -server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).\ -""") - - -def test_a_named_server_or_an_explicit_audience_satisfies_the_audience_requirement() -> None: - """SDK-defined: naming the server or setting `RequestStateSecurity(audience=...)` both construct.""" - MCPServer("named", request_state_security=RequestStateSecurity.ephemeral()) - MCPServer(request_state_security=RequestStateSecurity.ephemeral(audience="svc"))