Require integrity protection for MRTR requestState (#3032)

This commit is contained in:
Max 2026-06-30 21:30:32 +01:00 • committed by GitHub
commit 7322ca56f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3338 additions and 183 deletions

View file

@ -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 installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[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.

View file

@ -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.
* A `request_state` you set is sealed before it crosses the wire and verified on the echo, like everything else on the server; **[Protecting `requestState`](#protecting-requeststate)** below covers what the seal gives you and when you need to configure keys.
* 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.
@ -84,6 +85,78 @@ 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.
`MCPServer` protects it by default. Every server seals outgoing `requestState` and verifies every echo — resolver state and hand-built state alike — under a key generated at process start. You configure nothing, write plaintext, and read plaintext; the wire only ever carries an opaque encrypted token.
The default key lives and dies with the process, which is the one thing you must know before deploying beyond a single process:
```python
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
```
* **The default (no configuration)** suits a single process: stdio, or exactly one HTTP worker. A retry that lands on a different worker, a different instance behind a load balancer, or the same server after a restart is sealed under a key that process doesn't have — the client gets the frozen rejection below and must start the flow over.
* **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one.
* 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.
### What the seal carries
Default or configured, `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 principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. 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. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected.
* **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.** Every resolver answer is pinned to the rendered question the client was shown, both on the round it first arrives and when a recorded answer is reused later. Redeploy with a reworded message or a changed schema and the server re-asks instead of consuming 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, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.)
### 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, where you unwrap a data key once at startup and keep the per-token crypto local:
```python title="server.py" hl_lines="12 26-27 34-35 38"
--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, whether tampered, expired, replayed against a different request or principal, or 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. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters.
### Hand-built state
A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies.
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: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is 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.
@ -108,5 +181,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, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[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)**.

View file

@ -131,7 +131,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

View file

@ -0,0 +1,38 @@
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from mcp.server import MCPServer
from mcp.server.mcpserver import InvalidRequestState, RequestStateSecurity
PREFIX = "kms1." # format version; fed to GCM as associated data, so it is bound under the tag
def unwrap_data_key() -> bytes:
"""One KMS call at process start, kms.decrypt(CiphertextBlob=...); every token after that is local crypto."""
return os.urandom(32) # stand-in for the unwrapped 32-byte data key
class EnvelopeCodec:
def __init__(self, data_key: bytes) -> None:
self._aesgcm = AESGCM(data_key)
def seal(self, payload: bytes) -> str:
nonce = os.urandom(12)
return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex()
def unseal(self, token: str) -> bytes:
if not token.startswith(PREFIX):
raise InvalidRequestState("unknown token format")
body = token[len(PREFIX) :]
try:
raw = bytes.fromhex(body)
if raw.hex() != body: # only the exact string seal() produced verifies
raise ValueError("non-canonical hex")
return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode())
except (ValueError, InvalidTag) as exc:
raise InvalidRequestState("token failed verification") from exc
mcp = MCPServer("Deployer", request_state_security=RequestStateSecurity(codec=EnvelopeCodec(unwrap_data_key())))

View file

@ -6,16 +6,13 @@ Server implementing all MCP features for conformance testing based on Conformanc
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import logging
from typing import Annotated, Any
import click
from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
@ -47,7 +44,7 @@ from mcp_types import (
TextResourceContents,
UnsubscribeRequestParams,
)
from mcp_types.jsonrpc import INVALID_PARAMS, MISSING_REQUIRED_CLIENT_CAPABILITY
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@ -100,8 +97,12 @@ watched_resource_content = "Watched resource content"
# Create event store for SSE resumability (SEP-1699)
event_store = InMemoryEventStore()
# Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret.
_REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key"
mcp = MCPServer(
name="mcp-conformance-test-server",
request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]),
)
@ -497,30 +498,12 @@ async def test_input_required_result_multi_round(ctx: Context) -> str | InputReq
)
# Fixed key for the conformance fixture; a real server would derive or rotate this.
_STATE_HMAC_KEY = b"everything-server-fixture-key"
def _seal_state(payload: str) -> str:
encoded = base64.urlsafe_b64encode(payload.encode()).decode()
sig = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest()
return f"{encoded}.{sig}"
def _unseal_state(state: str) -> str:
encoded, _, sig = state.partition(".")
expected = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest()
if not sig or not hmac.compare_digest(sig, expected):
raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification")
try:
return base64.urlsafe_b64decode(encoded).decode()
except (binascii.Error, UnicodeDecodeError) as e:
raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") from e
@mcp.tool()
async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult:
"""Tests that the server rejects a requestState that fails HMAC verification"""
"""Tests that the server rejects a tampered requestState echo.
The handler stays plaintext; tamper rejection happens in the SDK's request-state boundary.
"""
if ctx.request_state is None:
confirm = ElicitRequest(
params=ElicitRequestFormParams(
@ -528,9 +511,8 @@ async def test_input_required_result_tampered_state(ctx: Context) -> str | Input
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
return InputRequiredResult(input_requests={"confirm": confirm}, request_state=_seal_state("round-1"))
payload = _unseal_state(ctx.request_state)
return f"state-ok: {payload}"
return InputRequiredResult(input_requests={"confirm": confirm}, request_state="round-1")
return f"state-ok: {ctx.request_state}"
@mcp.tool()

View file

@ -128,7 +128,7 @@ opens with a banner saying what replaces it.
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current |
| **— feature stories —** | | |
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current |
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and the default `requestState` sealing (a tampered echo gets one frozen error) | current |
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |

View file

@ -3,15 +3,20 @@
Multi-round tool result: on the 2026-07-28 protocol a tool that needs user
input mid-call **returns** `resultType: "input_required"` with embedded
`inputRequests` and an opaque `requestState`, instead of pushing a
server→client request. The client fulfils the embedded requests and retries the
server-to-client request. The client fulfils the embedded requests and retries the
original `tools/call` carrying `inputResponses` and the echoed `requestState`.
The story shows both the `Client` auto-loop (one `await call_tool`, callbacks
fired transparently) and a manual `client.session` loop (the persistable form).
fired transparently) and a manual `client.session` loop (the persistable
form). Because `requestState` round-trips through the client, it also shows
the security surface that protects it: `MCPServer` seals state by default
under a process-local key, handlers keep writing plaintext, and the wire only
ever carries an opaque token. The manual loop tampers with the sealed token to
show what a forged echo gets back.
## Run it
```bash
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# HTTP: the client self-hosts the server on a free port, runs, then tears it
# down (the InputRequiredResult round-trip is 2026-era only)
uv run python -m stories.mrtr.client --http
# same, against the lowlevel-API server variant
@ -20,36 +25,55 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
## What to look at
- `client.py` `main` — the auto-loop is invisible at the call site:
- `server.py` `build_server`: no security configuration at all. The default
seals under a key generated at process start, which is right for a
single-process server like this one; a fleet (multi-worker or load-balanced)
shares keys with `request_state_security=RequestStateSecurity(keys=[...])`
so any instance can verify state another minted.
- `server.py` `deploy`: handlers stay plaintext. The first round returns
`InputRequiredResult(input_requests={...},
request_state="awaiting-confirm")` and the retry asserts
`ctx.request_state == "awaiting-confirm"`. The tool never touches the
crypto; the boundary seals on the way out and unseals the echo on the way
back in.
- `client.py` `main`: the auto-loop is invisible at the call site:
`Client(target, mode=mode, elicitation_callback=on_elicit)` then
`await client.call_tool("deploy", ...)`. The same `on_elicit` callback the
legacy push path uses is dispatched for each embedded `inputRequests` entry.
- `client.py` manual block — `client.session.call_tool(...,
- `client.py` manual block: `client.session.call_tool(...,
allow_input_required=True)` returns the raw `InputRequiredResult` so
`request_state` can be persisted between rounds; the retry is just another
`tools/call` with `input_responses=` / `request_state=`.
- `server.py` `deploy` — `ctx.input_responses` / `ctx.request_state` read the
retry payload; the first round returns
`InputRequiredResult(input_requests={...}, request_state=...)`, the second
returns the final string.
- `server_lowlevel.py` — same wire contract via `params.input_responses` /
`params.request_state` and a hand-built `InputRequiredResult`.
`request_state` can be persisted between rounds. The wire value is an opaque
sealed token, **not** the string the server code wrote. The client asserts
exactly that, then retries with one character of the token flipped and gets
the single frozen error every verification failure maps to: `-32602`,
`"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`.
The specific reason (tampered tag, expiry, wrong request, wrong principal)
appears only in the server's log, never on the wire. The untampered token
then completes the round normally.
- `server_lowlevel.py`: the lowlevel tier doesn't seal by default; the same
enforcement is one appended middleware:
`server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(),
default_audience=server.name))`.
## Caveats
- **Loop bound.** The auto-loop gives up after `input_required_max_rounds`
(default 10) with `InputRequiredRoundsExceededError`; raise it on the
`Client` ctor or drop to the manual loop.
- **`requestState` integrity is the server's job.** The client echoes it
byte-exact and never inspects it; the server MUST treat it as
attacker-controlled. The SDK ships no signing helper yet.
- **The default key dies with the process.** It is generated at startup and
held only in memory, so a server restart (or a retry landing on a different
instance) invalidates in-flight rounds: the client gets the same frozen
rejection and must start the flow over. Use
`RequestStateSecurity(keys=[...])` when state must survive either.
## Spec
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
[Input required tool results (server features)](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results),
[Multi-round-trip requests (security patterns)](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr)
## See also
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the
MCPServer tier: the questions a tool can declare instead of pushing by hand.
`legacy_elicitation/` and `sampling/`: the handshake-era push equivalents this
mechanism replaces on the 2026 protocol. `refund_desk/`: resolver DI at the
MCPServer tier: the questions a tool can declare instead of pushing by hand
(its elicited answers ride in the same sealed `requestState`).

View file

@ -2,6 +2,7 @@
import mcp_types as types
from mcp import MCPError
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
@ -27,14 +28,37 @@ async def main(target: Target, *, mode: str = "auto") -> None:
first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True)
assert isinstance(first, types.InputRequiredResult)
assert first.input_requests is not None and "confirm" in first.input_requests
assert first.request_state == "awaiting-confirm"
# Decline this time so the path diverges from the auto-loop run above.
# The boundary sealed server.py's plaintext "awaiting-confirm"; the wire token is opaque.
token = first.request_state
assert token is not None and token != "awaiting-confirm", token
responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")}
# Tamper demo: flipping any one character fails verification, and every failure
# maps to one frozen wire error; the real reason appears only in the server log.
i = len(token) // 2
tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :]
try:
await client.session.call_tool(
"deploy",
{"env": "staging"},
input_responses=responses,
request_state=tampered,
allow_input_required=True,
)
except MCPError as e:
assert e.code == types.INVALID_PARAMS
assert e.message == "Invalid or expired requestState"
assert e.data == {"reason": "invalid_request_state"}
else:
raise AssertionError("expected MCPError for a tampered requestState")
# The untampered token still completes the round; decline so this path diverges from the auto run.
second = await client.session.call_tool(
"deploy",
{"env": "staging"},
input_responses=responses,
request_state=first.request_state,
request_state=token,
allow_input_required=True,
)
assert isinstance(second, types.CallToolResult)

View file

@ -13,19 +13,19 @@ CONFIRM_SCHEMA: ElicitRequestedSchema = {
def build_server() -> MCPServer:
# requestState is sealed by default under a process-local key, which suits this
# single-process server; fleets share keys=[...] so any instance can verify.
mcp = MCPServer("mrtr-example")
@mcp.tool(description="Deploy to an environment, asking the user to confirm first.")
async def deploy(env: str, ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None or "confirm" not in responses:
# First round: ask the client to elicit confirmation. request_state is opaque
# to the client; here it carries the step name so the retry can verify the echo.
ask = ElicitRequest(
params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
)
# The boundary seals this plaintext request_state on the way out and unseals the echo on retry.
return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
# Retry round: the client echoed request_state byte-exact and supplied the answer.
assert ctx.request_state == "awaiting-confirm", ctx.request_state
answer = responses["confirm"]
if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"):

View file

@ -6,6 +6,7 @@ import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: types.ElicitRequestedSchema = {
@ -55,7 +56,11 @@ def build_server() -> Server[Any]:
return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")])
return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")])
return Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool)
server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool)
# Lowlevel opt-in: append the same boundary middleware MCPServer installs by
# default; the server name becomes the token audience.
server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name))
return server
if __name__ == "__main__":

View file

@ -29,7 +29,9 @@ uv run python -m stories.refund_desk.client --http
- `server.py` `refund_order` — the signature is the whole story: `order_id` and
`reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers
and never reach the input schema. `client.py` asserts `properties` and
`required` are exactly `{order_id, reason}`.
`required` are exactly `{order_id, reason}`. At 2026 the resolver's elicited
answers ride between rounds inside a `requestState` the SDK seals by default;
see `mrtr/` for the full security walk-through.
- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order
returns `Scope(full=True)` directly; only a multi-line order returns
`Elicit(...)`. The ORD-7001 call completes with zero elicitations.

View file

@ -103,6 +103,8 @@ def ask_restock(
def build_server() -> MCPServer:
# Elicited answers ride between rounds in a requestState the SDK seals by default;
# see mrtr/ for the full security walk-through.
mcp = MCPServer("refund-desk")
@mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.")

View file

@ -13,7 +13,7 @@ from __future__ import annotations
from collections.abc import Mapping
from functools import cache
from types import MappingProxyType, UnionType
from typing import Any, Final, Literal, TypeVar, get_args
from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args
from pydantic import BaseModel, TypeAdapter
@ -28,6 +28,7 @@ __all__ = [
"CLIENT_REQUESTS",
"CLIENT_RESULTS",
"CacheableMethod",
"INPUT_REQUIRED_METHODS",
"MONOLITH_NOTIFICATIONS",
"MONOLITH_REQUESTS",
"MONOLITH_RESULTS",
@ -36,6 +37,7 @@ __all__ = [
"SERVER_RESULTS",
"SPEC_CLIENT_METHODS",
"SPEC_CLIENT_NOTIFICATION_METHODS",
"is_input_required",
"parse_client_notification",
"parse_client_request",
"parse_client_result",
@ -423,6 +425,22 @@ CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(
)
"""Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`."""
INPUT_REQUIRED_METHODS: Final[frozenset[str]] = frozenset(
method
for method, row in MONOLITH_RESULTS.items()
if any(
issubclass(arm, types.InputRequiredResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,))
)
)
"""Methods whose results may be `InputRequiredResult`, derived from `MONOLITH_RESULTS`."""
def is_input_required(result: object) -> TypeGuard[types.InputRequiredResult | dict[str, Any]]:
"""True when `result` is an `input_required` interim result, typed or wire-shaped."""
if isinstance(result, types.InputRequiredResult):
return True
return isinstance(result, Mapping) and cast("Mapping[str, Any]", result).get("resultType") == "input_required"
# --- Parse functions ---

View file

@ -609,7 +609,9 @@ class Client:
callbacks and the call is retried automatically (up to
`input_required_max_rounds`). To drive the loop yourself — e.g. to
persist `request_state` across process restarts — use
`client.session.call_tool(..., allow_input_required=True)`.
`client.session.call_tool(..., allow_input_required=True)`. Persisted
state is still subject to the server's TTL, request binding, and key
lifetime; a server on the default process-local key rejects it after a restart.
Args:
name: The name of the tool to call.

View file

@ -7,7 +7,7 @@ from starlette.authentication import AuthCredentials, AuthenticationBackend, Sim
from starlette.requests import HTTPConnection
from starlette.types import Receive, Scope, Send
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components
class AuthenticatedUser(SimpleUser):
@ -34,13 +34,8 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for
a verifier that populates `subject` and `claims` from an introspection
response."""
token = user.access_token
issuer = (token.claims or {}).get("iss")
return AuthorizationContext(
client_id=token.client_id,
issuer=str(issuer) if issuer is not None else None,
subject=token.subject,
)
client_id, issuer, subject = principal_components(user.access_token)
return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject)
class BearerAuthBackend(AuthenticationBackend):

View file

@ -59,6 +59,17 @@ class AccessToken(BaseModel):
claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`)
def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]:
"""The (client_id, issuer, subject) triple identifying the principal a token represents.
The single source for "who is this token's principal": session ownership and
request-state binding both build on it. Components the token verifier does
not supply are `None`, so comparisons degrade to the remaining components.
"""
issuer = (token.claims or {}).get("iss")
return token.client_id, str(issuer) if issuer is not None else None, token.subject
RegistrationErrorCode = Literal[
"invalid_redirect_uri",
"invalid_client_metadata",

View file

@ -3,6 +3,14 @@
from mcp_types import Icon
from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding
from mcp.server.request_state import (
AESGCMRequestStateCodec,
InvalidRequestState,
RequestStateBoundary,
RequestStateCodec,
RequestStateSecurity,
authenticated_principal,
)
from .context import Context
from .resolve import (
@ -36,4 +44,10 @@ __all__ = [
"require_client_extension",
"ResourceSecurity",
"DEFAULT_RESOURCE_SECURITY",
"RequestStateSecurity",
"RequestStateCodec",
"RequestStateBoundary",
"AESGCMRequestStateCodec",
"InvalidRequestState",
"authenticated_principal",
]

View file

@ -28,7 +28,11 @@ Whether the consumer receives the unwrapped model or the full
from __future__ import annotations
import base64
import hashlib
import inspect
import json
import logging
import types
import typing
from collections.abc import Callable, Hashable, Mapping
@ -43,6 +47,7 @@ from mcp_types import (
ElicitRequestFormParams,
ElicitResult,
FormElicitationCapability,
InputRequest,
InputRequests,
InputRequiredResult,
InputResponses,
@ -61,6 +66,7 @@ from mcp.server.elicitation import (
)
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
from mcp.server.request_state import compact_json
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
@ -73,7 +79,9 @@ _ELICITATION_RESULT_MEMBERS = (AcceptedElicitation, DeclinedElicitation, Cancell
# `InputRequiredResult` rather than as a standalone server-to-client request.
# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added).
_INPUT_REQUIRED_VERSION = "2026-07-28"
_STATE_VERSION = 1
_STATE_VERSION = 3 # v3: recorded and pended outcomes pinned to ASCII-canonical question renders
logger = logging.getLogger(__name__)
class Resolve:
@ -369,7 +377,11 @@ class _Resolution:
self.context = context
self.input_required = input_required
self.answers: InputResponses = context.input_responses or {} if input_required else {}
self.state = _decode_state(context.request_state) if input_required else {}
decoded = _decode_state(context.request_state if input_required else None)
self.state = decoded.outcomes
# Digests of the questions asked last round: an answer is accepted only
# for the exact rendering the client was shown.
self.asked = decoded.asked
# In-call dedup keyed by resolver identity (distinguishes two instances of
# the same bound method); `persist` holds the wire-shaped record of each
# elicited outcome, keyed by its wire key - exactly what the next round's
@ -431,7 +443,8 @@ async def resolve_arguments(
injected[name] = outcome if wants_union else _unwrap(outcome, name)
if res.pending:
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist))
asked = {key: _request_digest(request) for key, request in res.pending.items()}
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist, asked))
return injected
@ -494,19 +507,25 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
if not res.input_required:
return await res.context.elicit(elicit.message, elicit.schema)
request = _elicit_request(elicit)
q = _request_digest(request)
# A recorded outcome from a prior round is consulted only here, after the body
# decided to ask, so a `request_state` entry can never stand in for a resolver's
# own computation. Re-validate it against the live `Elicit.schema`. A recorded
# outcome wins over a re-sent answer; an invalid entry self-deletes and falls
# through to the fresh answer (or to re-asking).
outcome = _restore_outcome(res, key, elicit.schema)
# own computation. A recorded outcome wins over a re-sent answer.
outcome = _restore_outcome(res, key, elicit.schema, q)
if outcome is not None:
return outcome
answer = res.answers.get(key)
# An answer counts only for the rendering recorded when it was asked; an answer to
# an unrecorded or differently-worded question re-asks instead of being consumed.
if answer is not None and res.asked.get(key) != q:
logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key)
answer = None
if answer is None:
_require_form_elicitation(res.context, key)
res.pending[key] = _elicit_request(elicit)
res.pending[key] = request
raise _Pending
if not isinstance(answer, ElicitResult):
raise ToolError(f"Resolver {key!r} received a non-elicitation response")
@ -521,12 +540,12 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
) from e
# Persist the exact wire content that just passed validation - never the
# model - so restoring next round revalidates the same bytes the client sent.
res.persist[key] = _StateEntry(action="accept", data=answer.content)
res.persist[key] = _StateEntry(action="accept", data=answer.content, q=q)
return AcceptedElicitation(data=data)
if answer.action == "decline":
res.persist[key] = _StateEntry(action="decline")
res.persist[key] = _StateEntry(action="decline", q=q)
return DeclinedElicitation()
res.persist[key] = _StateEntry(action="cancel")
res.persist[key] = _StateEntry(action="cancel", q=q)
return CancelledElicitation()
@ -595,37 +614,58 @@ class _StateEntry(BaseModel):
action: Literal["accept", "decline", "cancel"]
data: Any = None
q: str | None = None
"""Digest of the exact rendered question this outcome answered."""
def _request_digest(request: InputRequest) -> str:
"""Pin an outcome to the exact rendered question the client was shown.
A redeploy that rewords or reshapes a question re-asks it instead of reusing the recorded answer.
"""
params = request.params
rendered = compact_json(params.model_dump(mode="json", by_alias=True, exclude_none=True) if params else None)
digest = hashlib.sha256(rendered.encode()).digest()[:16]
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
class _State(BaseModel):
"""The decoded `request_state`: resolver outcomes from earlier rounds."""
"""The decoded `request_state`: resolver progress from earlier rounds."""
v: int
outcomes: dict[str, _StateEntry] = {}
asked: dict[str, str] = {}
"""Question digest of each elicitation asked last round, keyed by wire key."""
def _decode_state(request_state: str | None) -> dict[str, _StateEntry]:
def _decode_state(request_state: str | None) -> _State:
"""Decode the per-call resolution progress from `request_state`.
`request_state` is client-trusted (integrity sealing is a follow-up); validate
it through `_State` and treat anything malformed as "no progress yet".
Parsed with stdlib `json.loads` because `_encode_state` may emit escaped
lone surrogates, which pydantic's JSON parser rejects. The string arrives
boundary-authenticated, so malformed content or a version mismatch is
drift within the operator's own fleet (e.g. a rolling upgrade) and is
treated as "no progress yet".
"""
empty = _State(v=_STATE_VERSION)
if not request_state:
return {}
return empty
try:
state = _State.model_validate_json(request_state)
except ValidationError:
return {}
return state.outcomes if state.v == _STATE_VERSION else {}
state = _State.model_validate(json.loads(request_state))
except ValueError:
return empty
return state if state.v == _STATE_VERSION else empty
def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
"""Encode recorded elicitation outcomes (keyed by wire key) for the next round.
def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str:
"""Encode recorded outcomes and asked-question digests for the next round.
Entries already hold the client's wire-shaped data exactly as it was sent (and
validated), so encoding is pure wrapping: encode-restore is the identity.
Outcome entries already hold the client's wire-shaped data exactly as it was
sent (and validated), so encoding is pure wrapping: encode-restore is the
identity.
"""
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()
state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked))
return compact_json(state.model_dump(mode="json"))
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]:
@ -642,12 +682,12 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat
return _accepted(schema.model_validate(entry.data))
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None:
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None:
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
`request_state` is client-trusted, so an entry whose data fails validation gets
the `_decode_state` treatment - dropped as if no progress was recorded, so the
question is asked again - rather than surfacing a validation error.
An entry pinned to a question digest other than `q`, or whose accepted
data fails validation against the live `schema`, is dropped as if no
progress was recorded, so the question is asked again.
Carries the original decoded entry forward unchanged in `res.persist`: if a
later resolver is still pending, the next round's `request_state` is built from
@ -657,6 +697,9 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> Eli
entry = res.state.get(key)
if entry is None:
return None
if entry.q != q:
del res.state[key]
return None
try:
outcome = _outcome_from_state(entry, schema)
except ValidationError:

View file

@ -83,6 +83,7 @@ from mcp.server.mcpserver.resources import (
from mcp.server.mcpserver.tools import Tool, ToolManager
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
@ -133,6 +134,15 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
auth: AuthSettings | None
_MISSING_AUDIENCE = (
"request_state_security is configured but this server has no name. Sealed\n"
"requestState carries the server name as an audience claim, so state minted by\n"
"another service that shares the same keys is rejected; unnamed servers would\n"
"all stamp the same placeholder and the check would mean nothing. Name the\n"
'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).'
)
def lifespan_wrapper(
app: MCPServer[LifespanResultT],
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]],
@ -170,6 +180,7 @@ class MCPServer(Generic[LifespanResultT]):
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
auth: AuthSettings | None = None,
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
request_state_security: RequestStateSecurity | None = None,
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
):
self._resource_security = resource_security
@ -210,6 +221,17 @@ class MCPServer(Generic[LifespanResultT]):
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
)
# Ordering: inside OpenTelemetry (spans record the sealed wire form),
# outside extension interceptors (extensions see plaintext).
if request_state_security is None:
security = RequestStateSecurity.ephemeral()
else:
# A supplied policy usually means shared keys, where the audience claim is
# what separates services; an unnamed server would stamp the placeholder.
if not name and request_state_security.audience is None:
raise ValueError(_MISSING_AUDIENCE)
security = request_state_security
self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name))
# Validate auth configuration
if self.settings.auth is not None:
if auth_server_provider and token_verifier: # pragma: no cover

View file

@ -0,0 +1,454 @@
"""Integrity protection for the multi-round-trip `requestState` (MCP 2026-07-28).
The spec requires servers to treat the client-echoed `requestState` as
attacker-controlled: `RequestStateBoundary` seals every outgoing value and
verifies every inbound echo, so handlers only ever see plaintext they minted.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import math
import os
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import replace
from typing import Any, NoReturn, Protocol, cast
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import principal_components
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.shared.exceptions import MCPError
__all__ = [
"AESGCMRequestStateCodec",
"InvalidRequestState",
"RequestStateBoundary",
"RequestStateCodec",
"RequestStateSecurity",
"authenticated_principal",
]
logger = logging.getLogger(__name__)
class InvalidRequestState(Exception):
"""A sealed `requestState` token failed verification.
The message is a log-only reason code; the boundary never puts it on the wire.
"""
class RequestStateCodec(Protocol):
"""Authenticated crypto over the framework's request-state envelope.
The framework stamps and re-verifies every envelope claim (expiry, request
binding, principal); a codec only provides integrity and, ideally,
confidentiality (a sign-only codec leaves the payload client-readable).
Requirements: `unseal(seal(payload))` round-trips, and `unseal` raises
`InvalidRequestState` for any token it did not mint unmodified; tokens
never name their algorithm (version with a format prefix bound under the
authentication tag, RFC 8725); comparisons are constant-time. Both methods
are synchronous, so cache key material rather than calling a KMS per token.
"""
def seal(self, payload: bytes) -> str:
"""Return an opaque URL-safe token protecting `payload`."""
...
def unseal(self, token: str) -> bytes:
"""Reverse `seal`.
Raises:
InvalidRequestState: Malformed, unauthentic, or unknown-key token.
"""
...
def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None:
"""Default principal binding: the authenticated (client, issuer, subject) identity.
Uses the same components session ownership uses, so two users of one OAuth
client are distinct principals whenever the token verifier supplies a
subject, and the binding degrades to the client identity when it does not.
Returns `None` (state not principal-bound) on unauthenticated transports.
"""
token = get_access_token()
if token is None:
return None
return compact_json(principal_components(token))
class RequestStateSecurity:
"""Policy for protecting `requestState`: codec, TTL, principal, audience.
Exactly one of `keys` or `codec`:
RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM
RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto
RequestStateSecurity.ephemeral() # process-local key
`keys` is the rotation ring: `keys[0]` seals, every key unseals.
Zero-downtime rotation, each phase fully rolled out before the next:
`keys=[old, new]`, then `keys=[new, old]`, then `keys=[new]` after one TTL.
The boundary enforces expiry, request binding, audience, and principal for
every codec, fail-closed in both directions. `audience=None` defers to the
boundary's `default_audience` (`MCPServer` passes its server name).
"""
codec: RequestStateCodec
ttl: float
bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None
audience: str | None
def __init__(
self,
*,
keys: Sequence[bytes | bytearray | str] | None = None,
codec: RequestStateCodec | None = None,
ttl: float = 600.0,
bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None = authenticated_principal,
audience: str | None = None,
) -> None:
if (keys is None) == (codec is None):
raise ValueError("RequestStateSecurity takes exactly one of keys= or codec=")
if not (math.isfinite(ttl) and ttl > 0):
raise ValueError(f"request-state ttl must be a positive finite number, got {ttl!r}")
if keys is not None:
self.codec = AESGCMRequestStateCodec(keys)
else:
assert codec is not None
self.codec = codec
self.ttl = ttl
self.bind_principal = bind_principal
self.audience = audience
@classmethod
def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity:
"""Protection under a key generated now and held only by this process.
This is the policy `MCPServer` installs when `request_state_security=`
is omitted; call it yourself on the lowlevel tier or to set `ttl`/
`audience`. Suits single-process deployments (stdio, one HTTP worker):
state minted before a restart or by another worker is rejected.
Multi-instance deployments must share a key via `keys=[...]`.
"""
return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience)
_KDF_INFO = b"mcp/request-state/v1/aes-256-gcm"
_KID_INFO = b"mcp/request-state/v1/kid:"
_TOKEN_PREFIX = "v1."
_KID_LEN = 4
_NONCE_LEN = 12
def compact_json(value: Any, *, sort_keys: bool = False) -> str:
"""Canonical JSON for everything the state path digests or seals.
ASCII output keeps the encode total: a lone surrogate in client-supplied
text escapes instead of raising. Anything consuming this must parse with
stdlib `json.loads`, which accepts those escapes (pydantic's JSON parser
does not).
"""
return json.dumps(value, sort_keys=sort_keys, separators=(",", ":"))
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _b64u_decode(text: str) -> bytes:
"""Strict inverse of `_b64u`: only the canonical unpadded encoding decodes."""
raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
if _b64u(raw) != text:
raise ValueError("non-canonical base64url")
return raw
def _derive_key(secret: bytes) -> bytes:
"""Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key."""
return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret)
class AESGCMRequestStateCodec:
"""Built-in codec: AES-256-GCM under key(s) derived with HKDF-SHA256.
Tokens are encrypted, not merely signed, so clients cannot read the state.
`keys[0]` seals; all keys unseal (rotation, see `RequestStateSecurity`).
Each token carries a 4-byte non-secret key fingerprint for an O(1) ring
lookup, and the "v1." prefix and fingerprint are bound into the GCM
associated data, so a token cannot be replayed into another format version
or ring slot. Key bytes are copied at construction.
"""
def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None:
for i, key in enumerate(cast("Sequence[object]", keys)):
if not isinstance(key, bytes | bytearray | str):
# Never coerce: bytes(32) would silently build an all-zero key.
raise TypeError(
f"request-state keys must be bytes, bytearray, or str; keys[{i}] is {type(key).__name__}"
)
material = [k.encode() if isinstance(k, str) else bytes(k) for k in keys]
if not material:
raise ValueError("AESGCMRequestStateCodec requires at least one key")
for i, k in enumerate(material):
if len(k) < 32:
raise ValueError(
f"request-state keys must be at least 32 bytes of secret randomness; "
f"keys[{i}] is {len(k)} bytes. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
self._ring: dict[bytes, AESGCM] = {}
self._mint_kid = b""
for i, secret in enumerate(material):
key = _derive_key(secret)
kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN]
if kid in self._ring:
raise ValueError(f"keys[{i}] duplicates an earlier ring key")
self._ring[kid] = AESGCM(key)
if i == 0:
self._mint_kid = kid
def seal(self, payload: bytes) -> str:
kid = self._mint_kid
nonce = os.urandom(_NONCE_LEN)
sealed = self._ring[kid].encrypt(nonce, payload, _TOKEN_PREFIX.encode() + kid)
return _TOKEN_PREFIX + _b64u(kid + nonce + sealed)
def unseal(self, token: str) -> bytes:
if not token.startswith(_TOKEN_PREFIX):
raise InvalidRequestState("malformed")
try:
raw = _b64u_decode(token[len(_TOKEN_PREFIX) :])
except ValueError as exc:
raise InvalidRequestState("malformed") from exc
if len(raw) < _KID_LEN + _NONCE_LEN + 16:
raise InvalidRequestState("malformed")
kid, nonce, sealed = raw[:_KID_LEN], raw[_KID_LEN : _KID_LEN + _NONCE_LEN], raw[_KID_LEN + _NONCE_LEN :]
aead = self._ring.get(kid)
if aead is None:
raise InvalidRequestState("unknown key")
try:
return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid)
except InvalidTag:
raise InvalidRequestState("seal") from None
# The multi-round-trip carriers: the only methods whose results may carry `requestState`.
_MRTR_METHODS = INPUT_REQUIRED_METHODS
_ENVELOPE_VERSION = 1
_FUTURE_SKEW = 60.0
_PRINCIPAL_LABEL = b"mcp/request-state/principal:"
_RoundBinding = tuple[str, str, str | None]
"""The (target, args-digest, principal) one round's envelope binds, computed once per round."""
def _reject(method: str, reason: str) -> NoReturn:
"""Refuse a round: frozen wire error, real reason to the server log only."""
logger.warning("requestState rejected on %s: %s", method, reason)
raise MCPError(
code=INVALID_PARAMS,
message="Invalid or expired requestState",
data={"reason": "invalid_request_state"},
)
def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[str, str]:
"""Salient (target, args-digest) for the request a token binds to.
Per-method allowlist, never a denylist: a future wire field cannot silently join the digest.
"""
p: Mapping[str, Any] = params or {}
args: dict[str, Any] = {}
if method == "resources/read":
target = str(p.get("uri", ""))
else:
target, args = str(p.get("name", "")), p.get("arguments") or args
return target, _b64u(hashlib.sha256(compact_json(args, sort_keys=True).encode()).digest()[:16])
def _principal_claim(principal: str) -> str:
salt = os.urandom(8)
tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + _principal_bytes(principal)).digest()[:16]
return _b64u(salt + tag)
def _principal_matches(claim: str, principal: str) -> bool:
try:
raw = _b64u_decode(claim)
except ValueError:
return False
# A wrong-length claim never matches: compare_digest handles mismatched sizes.
expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + _principal_bytes(principal)).digest()[:16]
return hmac.compare_digest(raw[8:], expected)
def _principal_bytes(principal: str) -> bytes:
# The digest input is one-way and never decoded, so surrogatepass keeps it total.
return principal.encode("utf-8", "surrogatepass")
def _bound_principal(
security: RequestStateSecurity,
ctx: ServerRequestContext[Any, Any],
fail: Callable[[str], NoReturn],
) -> str | None:
"""Run `bind_principal` under the deny-on-error discipline, in one place for both directions.
`fail` converts a failure into the calling direction's wire shape: the
frozen rejection when verifying, the sanitized internal error when sealing.
"""
try:
principal = security.bind_principal(ctx) if security.bind_principal is not None else None
except Exception: # deny-on-error: a raising principal binding must fail closed
logger.exception("bind_principal raised while processing requestState on %s", ctx.method)
fail("principal binding error")
# The declared return type is str | None, but a user callback can ignore it.
if principal is not None and not isinstance(cast("object", principal), str):
fail(f"bind_principal returned {type(principal).__name__}, expected str or None")
return principal
class RequestStateBoundary:
"""Server middleware sealing/unsealing `requestState` at the wire boundary.
Acts only on the multi-round-trip carriers (tools/call, prompts/get,
resources/read); every other method passes through untouched.
Inbound state is verified (codec unseal plus claims check) and replaced
with the plaintext the server minted before any interceptor or handler
runs; failure answers -32602 with the frozen message "Invalid or expired
requestState", the real reason going to the server log only. Outbound, an
`input_required` result carrying `requestState` is sealed in a fresh
claims envelope; handlers and resolvers never call the codec.
`default_audience` seeds the audience claim when the policy sets none, and
must be stated explicitly: it is the service identity that stops state
minted by another service sharing the same keys. `MCPServer` installs this
middleware with its server name by default (under an ephemeral policy
unless `request_state_security=` supplies one); lowlevel `Server` users
append one to `server.middleware`, passing their server's name (or `None`
to deliberately leave tokens audience-free).
"""
def __init__(self, security: RequestStateSecurity, *, default_audience: str | None) -> None:
self._security = security
self._audience = security.audience if security.audience is not None else default_audience
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
if ctx.method not in _MRTR_METHODS:
return await call_next(ctx)
binding: _RoundBinding | None = None
if ctx.params is not None and ctx.params.get("requestState") is not None:
# An explicit JSON null counts as absent: stripping the field is already in any client's power.
plaintext, binding = self._unseal(ctx)
ctx = replace(ctx, params={**ctx.params, "requestState": plaintext})
result = await call_next(ctx)
return self._seal_result(ctx, result, binding)
def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding]:
assert ctx.params is not None
wire = ctx.params["requestState"]
if not isinstance(wire, str):
_reject(ctx.method, "non-string requestState")
security = self._security
try:
payload = security.codec.unseal(wire)
except InvalidRequestState as exc:
_reject(ctx.method, str(exc))
except Exception: # deny-on-error: a buggy custom codec must fail closed
logger.exception("requestState codec raised during unseal on %s", ctx.method)
_reject(ctx.method, "codec error")
try:
claims = json.loads(payload)
version, iat, exp, inner = claims["v"], claims["iat"], claims["exp"], claims["s"]
except (ValueError, KeyError, TypeError):
_reject(ctx.method, "malformed")
if version != _ENVELOPE_VERSION or not isinstance(inner, str):
_reject(ctx.method, "malformed")
now = time.time()
# Accept-conditions are stated positively so a NaN claim fails the comparison and rejects.
if not isinstance(iat, int | float) or not (iat <= now + _FUTURE_SKEW):
_reject(ctx.method, "minted in the future")
if not isinstance(exp, int | float) or not (now < exp):
_reject(ctx.method, "expired")
target, args_digest = _request_identity(ctx.method, ctx.params)
if claims.get("m") != ctx.method or claims.get("t") != target or claims.get("a") != args_digest:
_reject(ctx.method, "request binding")
if claims.get("aud") != self._audience:
_reject(ctx.method, "audience")
def fail_verify(reason: str) -> NoReturn:
_reject(ctx.method, reason)
principal = _bound_principal(security, ctx, fail_verify)
claim = claims.get("p")
if (claim is None) != (principal is None):
_reject(ctx.method, "principal drift")
if claim is not None and principal is not None:
if not isinstance(claim, str) or not _principal_matches(claim, principal):
_reject(ctx.method, "principal")
return inner, (target, args_digest, principal)
def _seal_result(
self, ctx: ServerRequestContext[Any, Any], result: HandlerResult, binding: _RoundBinding | None
) -> HandlerResult:
# Spec-path results arrive as wire mappings; a short-circuiting middleware may return a model.
if not is_input_required(result):
return result
state = result.get("requestState") if isinstance(result, Mapping) else result.request_state
if state is None:
return result
if isinstance(result, Mapping):
if not isinstance(state, str):
# Only a short-circuiting middleware can put a non-string here; nothing to seal.
return result
return {**result, "requestState": self._seal(ctx, state, binding)}
return result.model_copy(update={"request_state": self._seal(ctx, state, binding)})
def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str:
security = self._security
if binding is None:
def fail_seal(reason: str) -> NoReturn:
logger.error("refusing to seal requestState on %s: %s", ctx.method, reason)
raise MCPError(code=INTERNAL_ERROR, message="Internal error")
target, args_digest = _request_identity(ctx.method, ctx.params)
binding = (target, args_digest, _bound_principal(security, ctx, fail_seal))
target, args_digest, principal = binding
now = time.time()
claims: dict[str, Any] = {
"v": _ENVELOPE_VERSION,
"iat": now,
"exp": now + security.ttl,
"m": ctx.method,
"t": target,
"a": args_digest,
"s": state,
}
if self._audience is not None:
claims["aud"] = self._audience
if principal is not None:
claims["p"] = _principal_claim(principal)
payload = compact_json(claims).encode()
try:
return security.codec.seal(payload)
except Exception: # deny-on-error: a raising custom codec must not leak its failure
logger.exception("requestState codec raised during seal on %s", ctx.method)
raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None

Some files were not shown because too many files have changed in this diff Show more