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
9 changed files with 3338 additions and 183 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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