Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
77 changes: 77 additions & 0 deletions docs/advanced/multi-round-trip.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +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.
* 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.
Expand Down Expand Up @@ -84,6 +85,81 @@ 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.

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 two configurations:

```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())
```

* `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.

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

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

## 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.
Expand All @@ -108,5 +184,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 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)**.
14 changes: 14 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,20 @@ 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. 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-<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.
Expand Down
6 changes: 4 additions & 2 deletions docs/tutorial/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/tutorial/elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs_src/dependencies/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
4 changes: 2 additions & 2 deletions docs_src/dependencies/tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
4 changes: 2 additions & 2 deletions docs_src/dependencies/tutorial003.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
3 changes: 2 additions & 1 deletion docs_src/elicitation/tutorial004.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}

Expand Down
Loading
Loading