Require integrity protection for MRTR requestState (#3032)

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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