Skip to content

Commit 4d200f4

Browse files
committed
Require integrity protection for MRTR requestState
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.
1 parent 0b200ef commit 4d200f4

32 files changed

Lines changed: 3164 additions & 97 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
181181

182182
Each of these is one idea you now have the vocabulary for; each has its own chapter.
183183

184-
* `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)**.
184+
* `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)**).
185185
* `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.
186186
* `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.
187187

docs/advanced/multi-round-trip.md

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
3535

3636
`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:
3737

38-
```python title="server.py" hl_lines="21 23 25"
38+
```python title="server.py" hl_lines="6 21 23 25"
3939
--8<-- "docs_src/mrtr/tutorial004.py"
4040
```
4141

4242
* 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.
43+
* 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.
4344
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
4445
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
4546
* 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
8485
* 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.
8586
* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method.
8687

88+
## Protecting `requestState`
89+
90+
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.
91+
92+
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.
93+
94+
There are three choices:
95+
96+
```python
97+
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
98+
99+
# Multi-instance: one or more shared secret keys (>= 32 bytes each).
100+
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
101+
102+
# Single process (stdio, one HTTP worker): a key generated at startup.
103+
mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral())
104+
105+
# No protection. Read the caveats before reaching for this.
106+
mcp = MCPServer("wizard", request_state_security=RequestStateSecurity.unprotected())
107+
```
108+
109+
* `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.
110+
* `.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.
111+
* `.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.
112+
113+
### What the seal carries
114+
115+
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:
116+
117+
* **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.
118+
* **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.
119+
* **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.
120+
* **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.
121+
122+
All of that is the SDK's job — not yours, and not the codec's if you bring your own.
123+
124+
### Rotating keys
125+
126+
`keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next:
127+
128+
```python
129+
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
130+
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
131+
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
132+
```
133+
134+
Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout.
135+
136+
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.
137+
138+
### Bring your own crypto
139+
140+
`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:
141+
142+
```python title="server.py" hl_lines="12 29-30 33"
143+
--8<-- "docs_src/mrtr/tutorial005.py"
144+
```
145+
146+
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.
147+
148+
### When verification fails
149+
150+
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:
151+
152+
```json
153+
{"code": -32602, "message": "Invalid or expired requestState"}
154+
```
155+
156+
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.
157+
158+
### Hand-built state
159+
160+
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.
161+
162+
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)**.
163+
87164
## A 2026-07-28 result
88165

89166
`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
108185
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
109186
* 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.
110187
* 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.
188+
* `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)**).
111189

112190
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.

docs/migration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,24 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol
423423

424424
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.
425425

426+
### Servers that mint `requestState` must configure `request_state_security=`
427+
428+
`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:
429+
430+
```python
431+
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
432+
433+
mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral())
434+
```
435+
436+
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).
437+
438+
Three behavior changes ride along:
439+
440+
* 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.
441+
* 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=`.
442+
* 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.
443+
426444
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
427445

428446
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-<name>` 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.

docs/tutorial/dependencies.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ A tool's arguments come from the model. Some values never should: a price looked
88

99
Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`:
1010

11-
```python title="server.py" hl_lines="18-19 23"
11+
```python title="server.py" hl_lines="8 18-19 23"
1212
--8<-- "docs_src/dependencies/tutorial001.py"
1313
```
1414

1515
* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument.
1616
* 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.
1717
* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble.
18+
* `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.
1819

1920
!!! info
2021
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
131132
its question, an eliciting resolver must derive its question deterministically from the
132133
tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a
133134
timestamp) is re-derived on each round and must not appear in a question the answer is meant
134-
to bind to.
135+
to bind to. A question built from such volatile data makes every recorded answer look stale,
136+
so the server re-asks it on every round until the client's round limit ends the call.
135137

136138
## Recap
137139

docs/tutorial/elicitation.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,14 @@ The booking tool above weaves the question into its own body. When the question
8585

8686
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:
8787

88-
```python title="server.py" hl_lines="24-30 35-36"
88+
```python title="server.py" hl_lines="16 25-31 36-37"
8989
--8<-- "docs_src/elicitation/tutorial004.py"
9090
```
9191

9292
* `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.
9393
* `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.
9494
* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`.
95+
* `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.
9596

9697
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.
9798

docs_src/dependencies/tutorial001.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from pydantic import BaseModel
44

55
from mcp.server import MCPServer
6-
from mcp.server.mcpserver import Resolve
6+
from mcp.server.mcpserver import RequestStateSecurity, Resolve
77

8-
mcp = MCPServer("Bookshop")
8+
mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral())
99

1010
INVENTORY = {"Dune": 7, "Neuromancer": 0}
1111

docs_src/dependencies/tutorial002.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from pydantic import BaseModel
44

55
from mcp.server import MCPServer
6-
from mcp.server.mcpserver import Resolve
6+
from mcp.server.mcpserver import RequestStateSecurity, Resolve
77

8-
mcp = MCPServer("Bookshop")
8+
mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral())
99

1010
INVENTORY = {"Dune": 7, "Neuromancer": 0}
1111

docs_src/dependencies/tutorial003.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from pydantic import BaseModel, Field
44

55
from mcp.server import MCPServer
6-
from mcp.server.mcpserver import Elicit, Resolve
6+
from mcp.server.mcpserver import Elicit, RequestStateSecurity, Resolve
77

8-
mcp = MCPServer("Bookshop")
8+
mcp = MCPServer("Bookshop", request_state_security=RequestStateSecurity.ephemeral())
99

1010
INVENTORY = {"Dune": 7, "Neuromancer": 0}
1111

0 commit comments

Comments
 (0)