Skip to content

Commit 6c0b2ab

Browse files
committed
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.
1 parent b61d141 commit 6c0b2ab

25 files changed

Lines changed: 284 additions & 675 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)**. 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)**).
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: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT
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-
* 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.
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.
@@ -87,9 +87,9 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
8787

8888
## Protecting `requestState`
8989

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

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 templatenothing 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.
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 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.
9393

9494
There are two configurations:
9595

@@ -103,20 +103,20 @@ mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])
103103
mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral())
104104
```
105105

106-
* `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.
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 cryptoa KMS, an existing token service pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract.
106+
* `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.
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, 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.
109109

110110
### What the seal carries
111111

112112
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:
113113

114114
* **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.
115-
* **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.
115+
* **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.
116116
* **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.
117-
* **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.
117+
* **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.
118118

119-
All of that is the SDK's job not yours, and not the codec's if you bring your own.
119+
All of that is the SDK's job, not yours, and not the codec's if you bring your own.
120120

121121
### Rotating keys
122122

@@ -134,31 +134,31 @@ Keys are scoped to one service. The sealed envelope also carries the server's na
134134

135135
### Bring your own crypto
136136

137-
`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 KMSunwrap a data key once at startup, then keep the per-token crypto local:
137+
`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:
138138

139139
```python title="server.py" hl_lines="12 29-30 33"
140140
--8<-- "docs_src/mrtr/tutorial005.py"
141141
```
142142

143-
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.
143+
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.
144144

145145
### When verification fails
146146

147-
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:
147+
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:
148148

149149
```json
150150
{"code": -32602, "message": "Invalid or expired requestState"}
151151
```
152152

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.
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.
154154

155155
### Hand-built state
156156

157-
A `request_state` you set yourself returning `InputRequiredResult` from a tool, prompt, or resource-template functionnever 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.
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.
158158

159159
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.
160160

161-
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)**.
161+
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)**.
162162

163163
## A 2026-07-28 result
164164

@@ -184,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc
184184
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
185185
* 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.
186186
* 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.
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)**).
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 the authenticated client when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**).
188188

189189
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemer
435435

436436
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-
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.
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.
439439

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

0 commit comments

Comments
 (0)