Require integrity protection for MRTR requestState (#3032)

This commit is contained in:
Max
2026-06-30 21:30:32 +01:00
committed by GitHub
parent 985652491a
commit 7322ca56f4
29 changed files with 3338 additions and 183 deletions
+1 -1
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.
+74
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)**.
+2 -1
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
+38
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())))
@@ -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()
+1 -1
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 |
+44 -20
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
serverclient 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`).
+27 -3
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)
+3 -3
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"):
+6 -1
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__":
+3 -1
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.
+2
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.")
+19 -1
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 ---
+3 -1
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.
@@ -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):
+11
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",
+14
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",
]
+72 -29
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:
+22
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
+454
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
+1 -1
View File
@@ -204,7 +204,7 @@ class ServerRunner(Generic[LifespanT]):
if (hint := self.server.cache_hints.get(method)) is not None:
if isinstance(result, CacheableResult):
result = apply_cache_hint(result, hint)
elif isinstance(result, Mapping) and result.get("resultType") != "input_required":
elif isinstance(result, Mapping) and not _methods.is_input_required(result):
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
# Dump and serialize inside the chain so the OpenTelemetry span (the
+35 -1
View File
@@ -18,9 +18,10 @@ from mcp_types import (
TextContent,
)
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import InvalidRequestState
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -161,3 +162,36 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
],
)
)
def test_a_custom_codec_round_trips_what_it_sealed() -> None:
"""tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
assert token.startswith(tutorial005.PREFIX)
assert b"round-1" not in token.encode()
assert codec.unseal(token) == b"round-1"
def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None:
"""tutorial005: any token the codec did not mint intact raises `InvalidRequestState`."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
with pytest.raises(InvalidRequestState):
codec.unseal(token + "00")
with pytest.raises(InvalidRequestState):
codec.unseal("not-a-token")
def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None:
"""tutorial005: only the exact minted string verifies; rewritten spellings of it do not."""
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
token = codec.seal(b"round-1")
body = token.removeprefix(tutorial005.PREFIX)
for alias in (
body, # prefix stripped
tutorial005.PREFIX + body.upper(), # non-canonical hex case
tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip
):
with pytest.raises(InvalidRequestState):
codec.unseal(alias)
@@ -9,8 +9,18 @@ from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.types import Message, Receive, Scope, Send
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, ProviderTokenVerifier
from mcp.server.auth.middleware.bearer_auth import (
AuthenticatedUser,
BearerAuthBackend,
RequireAuthMiddleware,
authorization_context,
)
from mcp.server.auth.provider import (
AccessToken,
OAuthAuthorizationServerProvider,
ProviderTokenVerifier,
principal_components,
)
class MockOAuthProvider:
@@ -446,3 +456,16 @@ class TestRequireAuthMiddleware:
assert app.scope == scope
assert app.receive == receive
assert app.send == send
def test_authorization_context_is_built_from_principal_components() -> None:
"""Session ownership identifies the principal via the shared principal_components triple."""
token = AccessToken(
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
client_id, issuer, subject = principal_components(token)
assert authorization_context(AuthenticatedUser(token)) == {
"client_id": client_id,
"issuer": issuer,
"subject": subject,
}
+12 -1
View File
@@ -1,6 +1,6 @@
"""Tests for mcp.server.auth.provider module."""
from mcp.server.auth.provider import construct_redirect_uri
from mcp.server.auth.provider import AccessToken, construct_redirect_uri, principal_components
def test_construct_redirect_uri_no_existing_params():
@@ -77,3 +77,14 @@ def test_construct_redirect_uri_encoded_values():
# urlencode uses + for spaces by default
assert "state=test+state+with+spaces" in result
def test_principal_components_composes_client_issuer_subject():
"""The triple identifying a token's principal, degrading per missing component."""
bare = AccessToken(token="t", client_id="client-1", scopes=[])
assert principal_components(bare) == ("client-1", None, None)
full = AccessToken(
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
assert principal_components(full) == ("client-1", "https://as.example", "alice")
File diff suppressed because it is too large Load Diff
+25 -13
View File
@@ -1866,7 +1866,8 @@ async def test_read_resource_template_not_found():
assert exc_info.value.error.data == {"uri": "resource://users/999"}
async def test_tool_returning_input_required_result_reaches_client_unchanged():
async def test_tool_returning_input_required_result_reaches_client_sealed():
# Default posture: the wire carries an opaque sealed token, never the handler's plaintext.
mcp = MCPServer()
@mcp.tool()
@@ -1878,7 +1879,7 @@ async def test_tool_returning_input_required_result_reaches_client_unchanged():
result = await client.session.call_tool("ask", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
_assert_sealed(result.request_state, "round-1")
assert result.input_requests is not None
assert result.input_requests["roots"].method == "roots/list"
@@ -1927,6 +1928,13 @@ async def test_tool_reads_input_responses_and_request_state_from_context_on_retr
assert block.text == "Hello, Alice! (state=r1)"
def _assert_sealed(state: str | None, plaintext: str) -> None:
"""The wire form is an opaque sealed token, never the handler's plaintext."""
assert state is not None
assert state != plaintext
assert state.startswith("v1.")
def _ask_who() -> ElicitRequest:
return ElicitRequest(
params=ElicitRequestFormParams(
@@ -1940,9 +1948,9 @@ def _ask_who() -> ElicitRequest:
)
async def test_prompt_returning_input_required_result_reaches_client_unchanged():
"""A prompt function may return an InputRequiredResult and the pipeline passes it
through to the client (spec-mandated: SEP-2322 allows it on prompts/get)."""
async def test_prompt_returning_input_required_result_reaches_client_sealed():
"""A prompt function may return an InputRequiredResult and the pipeline delivers it
to the client with the state sealed (spec-mandated: SEP-2322 allows it on prompts/get)."""
mcp = MCPServer()
@mcp.prompt()
@@ -1954,7 +1962,7 @@ async def test_prompt_returning_input_required_result_reaches_client_unchanged()
result = await client.session.get_prompt("briefing", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
_assert_sealed(result.request_state, "round-1")
assert result.input_requests is not None
assert result.input_requests["who"].method == "elicitation/create"
@@ -2023,9 +2031,9 @@ async def test_resource_template_input_required_result_on_legacy_session_is_a_se
assert exc.value.error.message == "Handler returned an invalid result"
async def test_resource_template_returning_input_required_result_reaches_client_unchanged():
async def test_resource_template_returning_input_required_result_reaches_client_sealed():
"""A resource template function may return an InputRequiredResult and the pipeline
passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read)."""
delivers it with the state sealed (spec-mandated: SEP-2322 allows it on resources/read)."""
mcp = MCPServer()
@mcp.resource("ask://{topic}")
@@ -2037,7 +2045,7 @@ async def test_resource_template_returning_input_required_result_reaches_client_
result = await client.session.read_resource("ask://databases", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
_assert_sealed(result.request_state, "round-1")
assert result.input_requests is not None
assert result.input_requests["who"].method == "elicitation/create"
@@ -2121,22 +2129,26 @@ async def test_context_read_resource_keeps_outer_input_responses_from_the_nested
return f"{topic} content"
@mcp.tool()
async def outer(ctx: Context) -> str:
async def outer(ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="outer-state")
contents = list(await ctx.read_resource("ask://databases"))
assert isinstance(contents[0].content, str)
return contents[0].content
return f"{contents[0].content} (state={ctx.request_state})"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.session.call_tool("outer", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
result = await client.session.call_tool(
"outer",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state="outer-state",
request_state=r1.request_state,
)
assert isinstance(result, CallToolResult)
block = result.content[0]
assert isinstance(block, TextContent)
assert block.text == "databases content"
assert block.text == "databases content (state=outer-state)"
assert seen_responses == [None]
assert seen_state == [None]
+479
View File
@@ -0,0 +1,479 @@
"""Unit tests for `mcp.server.request_state`: codec, security policy, and default principal binding."""
import base64
import string
from collections.abc import Callable
from typing import Any, cast
import pytest
from inline_snapshot import snapshot
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, authorization_context
from mcp.server.auth.provider import AccessToken, principal_components
from mcp.server.context import ServerRequestContext
from mcp.server.request_state import (
AESGCMRequestStateCodec,
InvalidRequestState,
RequestStateSecurity,
authenticated_principal,
)
_TOKEN_PREFIX = "v1."
_KID_LEN = 4
_NONCE_LEN = 12
_GCM_TAG_LEN = 16
_BODY_FLOOR = _KID_LEN + _NONCE_LEN + _GCM_TAG_LEN
_B64URL_ALPHABET = set(string.ascii_letters + string.digits + "-_")
_KEY_A = b"a" * 32
_KEY_B = b"b" * 32
_KEY_OLD = b"o" * 32
_KEY_NEW = b"n" * 32
# Distinctive plaintext: opacity and log-secrecy assertions search for it.
_PAYLOAD = b"sentinel-plaintext-3f9c"
# `InvalidRequestState` messages are short log-only reason codes, never payload.
_REASON_CODE_MAX_LEN = 40
def _b64u_nopad(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _decode_body(token: str) -> bytes:
body = token.removeprefix(_TOKEN_PREFIX)
return base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
def _flip_body_byte(token: str, index: int) -> str:
raw = bytearray(_decode_body(token))
raw[index] ^= 0xFF
return _TOKEN_PREFIX + _b64u_nopad(bytes(raw))
def _flip_prefix_char(token: str) -> str:
return "x" + token[1:]
def _flip_kid_byte(token: str) -> str:
return _flip_body_byte(token, 0)
def _flip_nonce_byte(token: str) -> str:
return _flip_body_byte(token, _KID_LEN)
def _flip_ciphertext_byte(token: str) -> str:
return _flip_body_byte(token, _KID_LEN + _NONCE_LEN)
def _flip_tag_byte(token: str) -> str:
return _flip_body_byte(token, -1)
def _inject_junk_chars(body: str) -> str:
return body[:10] + "!@\n*" + body[10:]
def _append_newline(body: str) -> str:
return body + "\n"
def _append_padding(body: str) -> str:
return body + "=" * (-len(body) % 4 or 4)
def _bare_context() -> ServerRequestContext[Any, Any]:
return ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tools/call",
)
class _StaticCodec:
"""Minimal `RequestStateCodec` stand-in for policy tests; no real crypto."""
def seal(self, payload: bytes) -> str:
return payload.hex()
def unseal(self, token: str) -> bytes:
return bytes.fromhex(token)
# -- AESGCMRequestStateCodec --------------------------------------------------
@pytest.mark.parametrize(
"payload",
[
pytest.param(b"", id="empty"),
pytest.param(b"plain ascii state", id="ascii"),
pytest.param("ünïcødé 状態".encode(), id="multi-byte-utf8"),
pytest.param(bytes(range(256)), id="raw-binary"),
pytest.param(bytes(range(256)) * 256, id="64KiB"),
],
)
def test_seal_unseal_round_trips_any_payload(payload: bytes) -> None:
"""SDK-defined: the codec is byte-transparent, so any payload survives seal/unseal unchanged."""
codec = AESGCMRequestStateCodec([_KEY_A])
assert codec.unseal(codec.seal(payload)) == payload
def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext() -> None:
"""SDK-defined token format: "v1." plus unpadded base64url over kid(4) || nonce(12) || ciphertext+tag."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
assert token.startswith(_TOKEN_PREFIX)
body = token.removeprefix(_TOKEN_PREFIX)
assert "=" not in body
assert set(body) <= _B64URL_ALPHABET
assert len(_decode_body(token)) == _KID_LEN + _NONCE_LEN + len(_PAYLOAD) + _GCM_TAG_LEN
def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal() -> None:
"""SDK-defined: every seal draws a fresh nonce, so identical payloads yield distinct tokens that both verify."""
codec = AESGCMRequestStateCodec([_KEY_A])
first = codec.seal(_PAYLOAD)
second = codec.seal(_PAYLOAD)
assert first != second
assert codec.unseal(first) == _PAYLOAD
assert codec.unseal(second) == _PAYLOAD
@pytest.mark.parametrize(
"corrupt",
[
pytest.param(_flip_prefix_char, id="prefix-char"),
pytest.param(_flip_kid_byte, id="kid-byte"),
pytest.param(_flip_nonce_byte, id="nonce-byte"),
pytest.param(_flip_ciphertext_byte, id="ciphertext-byte"),
pytest.param(_flip_tag_byte, id="tag-byte"),
],
)
def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload(
corrupt: Callable[[str], str],
) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): any corrupted token region is rejected."""
codec = AESGCMRequestStateCodec([_KEY_A])
token = codec.seal(_PAYLOAD)
with pytest.raises(InvalidRequestState) as exc:
codec.unseal(corrupt(token))
message = str(exc.value)
assert len(message) <= _REASON_CODE_MAX_LEN
assert _PAYLOAD.decode() not in message
@pytest.mark.parametrize(
"token",
[
pytest.param("", id="empty-string"),
pytest.param(_b64u_nopad(b"\x00" * 64), id="missing-prefix"),
pytest.param(_TOKEN_PREFIX + "!!!not-base64!!!", id="garbage-after-prefix"),
pytest.param(_TOKEN_PREFIX + _b64u_nopad(b"\x00" * (_BODY_FLOOR - 1)), id="below-floor"),
],
)
def test_a_structurally_malformed_token_is_rejected(token: str) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec never minted fail."""
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_A]).unseal(token)
def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): a foreign-key token fails as "unknown key"."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
with pytest.raises(InvalidRequestState) as exc:
AESGCMRequestStateCodec([_KEY_B]).unseal(token)
assert str(exc.value) == "unknown key"
@pytest.mark.parametrize(
"ring",
[
pytest.param([_KEY_OLD, _KEY_NEW], id="rotation-phase-1"),
pytest.param([_KEY_NEW, _KEY_OLD], id="rotation-phase-2"),
],
)
def test_a_token_minted_under_the_old_key_unseals_under_any_ring_containing_it(ring: list[bytes]) -> None:
"""SDK-defined rotation: every ring key verifies, so old-key state survives both rollout phases."""
token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec(ring).unseal(token) == _PAYLOAD
def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None:
"""SDK-defined rotation: keys[0] is the minter, so [new, old] state verifies under [new] but not [old]."""
token = AESGCMRequestStateCodec([_KEY_NEW, _KEY_OLD]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) == _PAYLOAD
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_OLD]).unseal(token)
def test_a_token_minted_under_a_retired_key_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): retired-key state fails verification."""
token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD)
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_NEW]).unseal(token)
def test_an_empty_key_ring_is_rejected_at_construction() -> None:
"""SDK-defined: an empty ring is a configuration error caught at construction."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([])
assert str(exc.value) == snapshot("AESGCMRequestStateCodec requires at least one key")
def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> None:
"""SDK-defined: keys must carry at least 32 bytes; the error includes generation guidance."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([b"k" * 31])
assert str(exc.value) == snapshot(
"request-state keys must be at least 32 bytes of secret randomness; keys[0] is 31 bytes. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
def test_a_duplicate_key_in_the_ring_is_rejected_at_construction() -> None:
"""SDK-defined: duplicate ring keys are a rotation mistake caught at construction."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([_KEY_A, _KEY_A])
assert str(exc.value) == snapshot("keys[1] duplicates an earlier ring key")
def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> None:
"""SDK-defined: a non-key ring entry raises a TypeError naming its index and type, in codec and policy."""
with pytest.raises(TypeError) as exc:
AESGCMRequestStateCodec([_KEY_A, cast("Any", 32)])
assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[1] is int")
with pytest.raises(TypeError) as exc:
RequestStateSecurity(keys=[cast("Any", 32)])
assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[0] is int")
def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None:
"""SDK-defined: bytes, bytearray, and str keys interoperate in one ring."""
codec = AESGCMRequestStateCodec([_KEY_A, bytearray(_KEY_B), "c" * 32])
assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
assert codec.unseal(AESGCMRequestStateCodec([bytearray(_KEY_B)]).seal(_PAYLOAD)) == _PAYLOAD
assert codec.unseal(AESGCMRequestStateCodec(["c" * 32]).seal(_PAYLOAD)) == _PAYLOAD
def test_a_str_key_is_equivalent_to_its_utf8_bytes_form() -> None:
"""SDK-defined: a str key is utf-8 encoded, so it is the same ring key as its bytes spelling."""
token = AESGCMRequestStateCodec(["k" * 32]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec([b"k" * 32]).unseal(token) == _PAYLOAD
def test_bytearray_key_material_is_copied_at_construction() -> None:
"""SDK-defined: key bytes are copied at construction; mutating the caller's bytearray later has no effect."""
material = bytearray(b"m" * 32)
codec = AESGCMRequestStateCodec([cast("Any", material)])
minted_before_mutation = codec.seal(_PAYLOAD)
material[:] = b"X" * 32
assert codec.unseal(minted_before_mutation) == _PAYLOAD
assert AESGCMRequestStateCodec([b"m" * 32]).unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes() -> None:
"""SDK-defined: the token is encrypted, not merely signed, so the plaintext appears nowhere in it."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
assert _PAYLOAD.decode() not in token
assert _b64u_nopad(_PAYLOAD) not in token
assert _PAYLOAD.hex() not in token
assert _PAYLOAD not in _decode_body(token)
def test_every_substitution_of_the_final_token_character_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): canonical decoding
rejects every final-character substitution despite base64 don't-care padding bits."""
codec = AESGCMRequestStateCodec([_KEY_A])
body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX)
substitutions = [c for c in sorted(_B64URL_ALPHABET) if c != body[-1]]
assert len(substitutions) == 63
for c in substitutions:
with pytest.raises(InvalidRequestState):
codec.unseal(_TOKEN_PREFIX + body[:-1] + c)
@pytest.mark.parametrize(
"mangle",
[
pytest.param(_inject_junk_chars, id="junk-chars-injected"),
pytest.param(_append_newline, id="newline-appended"),
pytest.param(_append_padding, id="padding-appended"),
],
)
def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): lax-decoder aliases of a token are rejected."""
codec = AESGCMRequestStateCodec([_KEY_A])
body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX)
with pytest.raises(InvalidRequestState):
codec.unseal(_TOKEN_PREFIX + mangle(body))
def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): the prefix is tag-bound; "v2." replay fails."""
codec = AESGCMRequestStateCodec([_KEY_A])
token = codec.seal(_PAYLOAD)
with pytest.raises(InvalidRequestState):
codec.unseal("v2." + token.removeprefix(_TOKEN_PREFIX))
def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is tag-bound; transplanting it fails."""
raw_a = _decode_body(AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD))
raw_b = _decode_body(AESGCMRequestStateCodec([_KEY_B]).seal(_PAYLOAD))
assert raw_a[:_KID_LEN] != raw_b[:_KID_LEN]
transplanted = _TOKEN_PREFIX + _b64u_nopad(raw_a[:_KID_LEN] + raw_b[_KID_LEN:])
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_A, _KEY_B]).unseal(transplanted)
# -- RequestStateSecurity -----------------------------------------------------
def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None:
"""SDK-defined: keys= and codec= are mutually exclusive."""
with pytest.raises(ValueError) as exc:
RequestStateSecurity(keys=[_KEY_A], codec=_StaticCodec())
assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=")
def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None:
"""SDK-defined: a policy must name its codec; an empty policy is a mistake, not a posture."""
with pytest.raises(ValueError) as exc:
RequestStateSecurity()
assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=")
@pytest.mark.parametrize(
"ttl",
[
pytest.param(0.0, id="zero"),
pytest.param(-600.0, id="negative"),
pytest.param(float("nan"), id="nan"),
pytest.param(float("inf"), id="inf"),
],
)
def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl: float) -> None:
"""SDK-defined: zero, negative, NaN, and infinite ttl fail at construction for keys and ephemeral() alike."""
with pytest.raises(ValueError, match="positive finite"):
RequestStateSecurity(keys=[_KEY_A], ttl=ttl)
with pytest.raises(ValueError, match="positive finite"):
RequestStateSecurity.ephemeral(ttl=ttl)
def test_keys_produce_a_working_built_in_codec_on_the_policy() -> None:
"""SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on .codec."""
security = RequestStateSecurity(keys=[_KEY_A])
assert isinstance(security.codec, AESGCMRequestStateCodec)
assert security.codec.unseal(security.codec.seal(_PAYLOAD)) == _PAYLOAD
def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None:
"""SDK-defined: codec=... stores the caller's object unwrapped."""
codec = _StaticCodec()
security = RequestStateSecurity(codec=codec)
assert security.codec is codec
assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None:
"""SDK-defined: ephemeral() protects under a process-local key, so a sibling instance rejects its tokens."""
first = RequestStateSecurity.ephemeral()
second = RequestStateSecurity.ephemeral()
token = first.codec.seal(_PAYLOAD)
assert first.codec.unseal(token) == _PAYLOAD
with pytest.raises(InvalidRequestState):
second.codec.unseal(token)
def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None:
"""SDK-defined: audience is stored as given; None defers to the server tier's `default_audience`."""
assert RequestStateSecurity(keys=[_KEY_A]).audience is None
assert RequestStateSecurity(keys=[_KEY_A], audience="svc").audience == "svc"
assert RequestStateSecurity.ephemeral(audience="svc").audience == "svc"
def test_the_default_principal_binding_is_authenticated_principal() -> None:
"""SDK-defined: an unconfigured policy binds state to the authenticated OAuth client by default."""
assert RequestStateSecurity(keys=[_KEY_A]).bind_principal is authenticated_principal
def test_an_explicit_principal_binding_callable_is_stored() -> None:
"""SDK-defined: a custom bind_principal callable is stored as given."""
def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None:
return "tenant-1"
security = RequestStateSecurity(keys=[_KEY_A], bind_principal=tenant_binding)
assert security.bind_principal is tenant_binding
assert tenant_binding(_bare_context()) == "tenant-1"
# -- authenticated_principal ----------------------------------------------------
def test_authenticated_principal_is_none_without_an_auth_context() -> None:
"""SDK-defined: without an auth context the default binding derives no principal."""
assert authenticated_principal(_bare_context()) is None
@pytest.mark.parametrize(
("token", "expected"),
[
pytest.param(
AccessToken(token="at-1", client_id="client-123", scopes=[]),
'["client-123",null,null]',
id="client-only",
),
pytest.param(
AccessToken(token="at-2", client_id="client-123", scopes=[], subject="alice"),
'["client-123",null,"alice"]',
id="with-subject",
),
pytest.param(
AccessToken(
token="at-3", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"}
),
'["client-123","https://as.example","alice"]',
id="with-issuer-and-subject",
),
],
)
def test_authenticated_principal_is_the_tokens_client_issuer_subject_identity(
token: AccessToken, expected: str
) -> None:
"""SDK-defined: the default binding composes (client_id, issuer, subject), degrading per component."""
reset = auth_context_var.set(AuthenticatedUser(token))
try:
assert authenticated_principal(_bare_context()) == expected
finally:
auth_context_var.reset(reset)
def test_authenticated_principal_distinguishes_two_subjects_of_one_client() -> None:
"""SDK-defined: two users of the same OAuth client are distinct principals when subjects are supplied."""
alice = AccessToken(token="at-a", client_id="https://agent.example/client.json", scopes=[], subject="alice")
bob = AccessToken(token="at-b", client_id="https://agent.example/client.json", scopes=[], subject="bob")
principals: list[str | None] = []
for token in (alice, bob):
reset = auth_context_var.set(AuthenticatedUser(token))
try:
principals.append(authenticated_principal(_bare_context()))
finally:
auth_context_var.reset(reset)
assert principals[0] != principals[1]
def test_authenticated_principal_uses_the_same_components_as_session_ownership() -> None:
"""SDK-defined: the binding and authorization_context derive from one principal_components source."""
token = AccessToken(
token="at-1", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
assert authorization_context(AuthenticatedUser(token)) == {
"client_id": "client-123",
"issuer": "https://as.example",
"subject": "alice",
}
assert list(principal_components(token)) == ["client-123", "https://as.example", "alice"]
File diff suppressed because it is too large Load Diff
+15
View File
@@ -553,6 +553,21 @@ def test_cacheable_methods_mirror_the_cacheable_method_literal():
assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod))
def test_input_required_methods_mirror_the_monolith_input_required_arms():
"""MRTR weld: the spec's three multi-round-trip carriers are the only input_required methods."""
assert methods.INPUT_REQUIRED_METHODS == frozenset({"prompts/get", "resources/read", "tools/call"})
def test_is_input_required_matches_typed_and_wire_shapes():
"""SDK-defined predicate: True only for the typed model and the tagged wire mapping."""
assert methods.is_input_required(types.InputRequiredResult(request_state="s"))
assert methods.is_input_required({"resultType": "input_required", "inputRequests": {}})
assert not methods.is_input_required({"resultType": "complete", "content": []})
assert not methods.is_input_required({})
assert not methods.is_input_required(types.CallToolResult(content=[]))
assert not methods.is_input_required(None)
def test_minimal_request_bodies_parse_through_every_request_row():
for (method, version), surface_type in methods.CLIENT_REQUESTS.items():
parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type])