You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/advanced/low-level-server.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
181
181
182
182
Each of these is one idea you now have the vocabulary for; each has its own chapter.
183
183
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)**).
185
185
*`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.
186
186
*`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.
Copy file name to clipboardExpand all lines: docs/advanced/multi-round-trip.md
+79-1Lines changed: 79 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -35,11 +35,12 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
35
35
36
36
`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:
37
37
38
-
```python title="server.py" hl_lines="21 23 25"
38
+
```python title="server.py" hl_lines="6 21 23 25"
39
39
--8<--"docs_src/mrtr/tutorial004.py"
40
40
```
41
41
42
42
* 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.
43
44
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
44
45
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
45
46
* 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
84
85
* 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.
85
86
* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method.
86
87
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).
*`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:
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
+
87
164
## A 2026-07-28 result
88
165
89
166
`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
108
185
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
109
186
* 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.
110
187
* 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)**).
111
189
112
190
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.
Copy file name to clipboardExpand all lines: docs/migration.md
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -423,6 +423,24 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol
423
423
424
424
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.
425
425
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
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
+
426
444
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
427
445
428
446
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.
Copy file name to clipboardExpand all lines: docs/tutorial/dependencies.md
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,13 +8,14 @@ A tool's arguments come from the model. Some values never should: a price looked
8
8
9
9
Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`:
10
10
11
-
```python title="server.py" hl_lines="18-19 23"
11
+
```python title="server.py" hl_lines="8 18-19 23"
12
12
--8<--"docs_src/dependencies/tutorial001.py"
13
13
```
14
14
15
15
*`check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument.
16
16
* 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.
17
17
* 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.
18
19
19
20
!!! info
20
21
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
131
132
its question, an eliciting resolver must derive its question deterministically from the
132
133
tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a
133
134
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.
Copy file name to clipboardExpand all lines: docs/tutorial/elicitation.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -85,13 +85,14 @@ The booking tool above weaves the question into its own body. When the question
85
85
86
86
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:
*`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.
93
93
*`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.
94
94
* 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.
95
96
96
97
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.
0 commit comments