Require integrity protection for MRTR requestState (#3032)
This commit is contained in:
parent
985652491a
commit
7322ca56f4
8 changed files with 3338 additions and 183 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,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__":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Reference in a new issue