Skip to content

Commit b61d141

Browse files
committed
Scope the construction-time requirement to resolver tools
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.
1 parent d078a9d commit b61d141

11 files changed

Lines changed: 261 additions & 564 deletions

File tree

docs/advanced/multi-round-trip.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +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="6 21 23 25"
38+
```python title="server.py" hl_lines="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.
43+
* 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.
4444
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
4545
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
4646
* 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
8989

9090
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.
9191

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.
92+
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.
9393

94-
There are three choices:
94+
There are two configurations:
9595

9696
```python
9797
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
@@ -101,18 +101,15 @@ mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])
101101

102102
# Single process (stdio, one HTTP worker): a key generated at startup.
103103
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())
107104
```
108105

109106
* `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.
107+
* `.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.
108+
* 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.
112109

113110
### What the seal carries
114111

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:
112+
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:
116113

117114
* **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.
118115
* **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
153150
{"code": -32602, "message": "Invalid or expired requestState"}
154151
```
155152

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.
153+
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.
157154

158155
### Hand-built state
159156

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.
157+
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.
158+
159+
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.
161160

162161
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)**.
163162

@@ -185,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc
185184
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
186185
* 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.
187186
* 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)**).
187+
* `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)**).
189188

190189
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: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -423,23 +423,19 @@ 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=`
426+
### Tools with `Resolve(...)` parameters require `request_state_security=`
427427

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:
428+
`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:
429429

430430
```python
431431
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
432432

433433
mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral())
434434
```
435435

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).
436+
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).
437437

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.
438+
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.
443439

444440
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
445441

docs_src/mrtr/tutorial004.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
22

3-
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
3+
from mcp.server.mcpserver import Context, MCPServer
44
from mcp.server.mcpserver.prompts.base import UserMessage
55

6-
mcp = MCPServer("Briefing", request_state_security=RequestStateSecurity.ephemeral())
6+
mcp = MCPServer("Briefing")
77

88
ASK_AUDIENCE = ElicitRequest(
99
params=ElicitRequestFormParams(

examples/stories/mrtr/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
2727

2828
- `server.py` `build_server` — the whole security opt-in is one constructor
2929
argument: `request_state_security=RequestStateSecurity.ephemeral()`.
30+
Opting in is this server's choice — only tools with `Resolve(...)`
31+
parameters are required to configure protection; a hand-built flow like
32+
`deploy` would otherwise send its state across the wire as plaintext.
3033
`ephemeral()` generates a key at process start, which is right for a
3134
single-process server like this one; a fleet (multi-worker or load-balanced)
3235
shares keys with `RequestStateSecurity(keys=[...])` so any instance can

0 commit comments

Comments
 (0)