Skip to content

Commit 471cdd9

Browse files
committed
Address review feedback on requestState protection
- Pin pending questions to their rendered wording: an answer arriving after a redeploy reworded the question is re-asked, not consumed. The state envelope (now v3) records the digest of every question asked, and discarded answers are logged. - Make every encode on the state path total via one compact_json helper, and parse state with stdlib json so escaped lone surrogates round-trip; surrogate-bearing arguments digest instead of failing the round. - Complete the deny-on-error discipline: a raising codec seal and non-string bind_principal returns fail closed in both directions through one shared _bound_principal helper. - Mint envelope timestamps on the float clock so the configured ttl is the effective ttl; sub-second ttls were expired at mint. - Bind the default principal to the token's (client_id, issuer, subject) through the shared principal_components, the same identity session ownership uses; two users of one OAuth client are distinct principals. - Require an explicit audience source: RequestStateBoundary takes default_audience explicitly, and a configured MCPServer must have a real (non-empty) name or set audience=. - Make the tutorial codec example enforce the strict token contract (prefix check, canonical hex).
1 parent 6c0b2ab commit 471cdd9

18 files changed

Lines changed: 595 additions & 99 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
181181

182182
Each of these is one idea you now have the vocabulary for; each has its own chapter.
183183

184-
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...])))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**).
184+
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is required at construction: the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` enforces (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**).
185185
* `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.
186186
* `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.
187187

docs/advanced/multi-round-trip.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,9 @@ mcp = MCPServer("dev", request_state_security=RequestStateSecurity.ephemeral())
112112
With either built-in configuration, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to:
113113

114114
* **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.
115-
* **The authenticated client.** When the request carries an OAuth access token the SDK validated, the state is bound to that `client_id`: a token minted for one principal fails under another. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal.
115+
* **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.
116116
* **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.
117-
* **The exact question asked.** A recorded resolver answer is pinned to the rendered question the client was shown. Redeploy with a reworded message or a changed schema and the server re-asks instead of reusing a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call.
117+
* **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.
118118

119119
All of that is the SDK's job, not yours, and not the codec's if you bring your own.
120120

@@ -130,13 +130,13 @@ RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out,
130130

131131
Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout.
132132

133-
Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim by default, so a token minted by a different service that happens to share a secret is rejected anyway. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted.
133+
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, which is why `MCPServer` refuses `request_state_security=` on an unnamed server. `RequestStateSecurity(audience=...)` overrides the claim for deliberate multi-service topologies where one service must accept state another minted.
134134

135135
### Bring your own crypto
136136

137137
`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:
138138

139-
```python title="server.py" hl_lines="12 29-30 33"
139+
```python title="server.py" hl_lines="12 26-27 34-35 38"
140140
--8<-- "docs_src/mrtr/tutorial005.py"
141141
```
142142

@@ -184,6 +184,6 @@ The low-level `Server` is the no-batteries tier: nothing is required at construc
184184
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
185185
* 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.
186186
* 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.
187-
* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated client when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**).
187+
* `requestState` comes back as client-supplied input. `MCPServer` requires a `request_state_security=` choice before it will register a `Resolve(...)` tool, and seals hand-built state with the same machinery once you configure it. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**).
188188

189189
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ from mcp.server.mcpserver import MCPServer, RequestStateSecurity
433433
mcp = MCPServer("my-server", request_state_security=RequestStateSecurity.ephemeral())
434434
```
435435

436-
Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate).
436+
Multi-instance deployments share secret keys instead (`RequestStateSecurity(keys=[...])`) so every instance can verify what a sibling minted. A configured server must also be named (or pass `RequestStateSecurity(audience=...)`): the name becomes the sealed token's audience claim, so an unnamed server raises `ValueError` at construction. The choices, what gets sealed, key rotation, and custom codecs are covered in [Protecting `requestState`](advanced/multi-round-trip.md#protecting-requeststate).
437437

438438
On a protected server the wire `requestState` is an opaque sealed token, and `ctx.request_state` returns the verified plaintext your handler originally wrote. Sealing and verification happen at the wire boundary, so handler code reads exactly what it minted. Hand-built `requestState` (a tool, prompt, or resource-template function returning `InputRequiredResult` itself) is unaffected unless you opt in, in which case it is sealed and verified automatically too.
439439

docs_src/mrtr/tutorial005.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ def seal(self, payload: bytes) -> str:
2323
return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex()
2424

2525
def unseal(self, token: str) -> bytes:
26+
if not token.startswith(PREFIX):
27+
raise InvalidRequestState("unknown token format")
28+
body = token[len(PREFIX) :]
2629
try:
27-
raw = bytes.fromhex(token.removeprefix(PREFIX))
30+
raw = bytes.fromhex(body)
31+
if raw.hex() != body: # only the exact string seal() produced verifies
32+
raise ValueError("non-canonical hex")
2833
return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode())
2934
except (ValueError, InvalidTag) as exc:
3035
raise InvalidRequestState("token failed verification") from exc

examples/stories/mrtr/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
5656
then completes the round normally.
5757
- `server_lowlevel.py`: the lowlevel tier has no construction-time
5858
requirement; the same enforcement is one appended middleware:
59-
`server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral()))`.
59+
`server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(),
60+
default_audience=server.name))`.
6061

6162
## Caveats
6263

examples/stories/mrtr/server_lowlevel.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ async def call_tool(
5757
return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")])
5858

5959
server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool)
60-
# Lowlevel opt-in: append the same boundary middleware MCPServer installs from request_state_security=.
61-
server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral()))
60+
# Lowlevel opt-in: append the same boundary middleware MCPServer installs from
61+
# request_state_security=; the server name becomes the token audience.
62+
server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name))
6263
return server
6364

6465

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from starlette.requests import HTTPConnection
88
from starlette.types import Receive, Scope, Send
99

10-
from mcp.server.auth.provider import AccessToken, TokenVerifier
10+
from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components
1111

1212

1313
class AuthenticatedUser(SimpleUser):
@@ -34,13 +34,8 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
3434
See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for
3535
a verifier that populates `subject` and `claims` from an introspection
3636
response."""
37-
token = user.access_token
38-
issuer = (token.claims or {}).get("iss")
39-
return AuthorizationContext(
40-
client_id=token.client_id,
41-
issuer=str(issuer) if issuer is not None else None,
42-
subject=token.subject,
43-
)
37+
client_id, issuer, subject = principal_components(user.access_token)
38+
return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject)
4439

4540

4641
class BearerAuthBackend(AuthenticationBackend):

src/mcp/server/auth/provider.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ class AccessToken(BaseModel):
5959
claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`)
6060

6161

62+
def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]:
63+
"""The (client_id, issuer, subject) triple identifying the principal a token represents.
64+
65+
The single source for "who is this token's principal": session ownership and
66+
request-state binding both build on it. Components the token verifier does
67+
not supply are `None`, so comparisons degrade to the remaining components.
68+
"""
69+
issuer = (token.claims or {}).get("iss")
70+
return token.client_id, str(issuer) if issuer is not None else None, token.subject
71+
72+
6273
RegistrationErrorCode = Literal[
6374
"invalid_redirect_uri",
6475
"invalid_client_metadata",

0 commit comments

Comments
 (0)