Skip to content

[Server] Bind requestState to the request it was minted for - #625

Open
BrocksiNet wants to merge 3 commits into
modelcontextprotocol:mainfrom
BrocksiNet:feat/request-state-binder
Open

BrocksiNet wants to merge 3 commits into
modelcontextprotocol:mainfrom
BrocksiNet:feat/request-state-binder

Conversation

@BrocksiNet

@BrocksiNet BrocksiNet commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Builder::setRequestState($key, $ttl, binder: $binder) takes an optional RequestStateBinderInterface, which binds every requestState the modern era mints to the request it was minted for.
    The binder is called once per request with the Request as the client sent it (before any RequestEvent listener) and its ?AccessToken.
    Its value is covered by the MAC on mint and recomputed on verify, so a state echoed on a request that binds to another value fails with the same -32602 "The supplied requestState failed verification." (HTTP 400) as a forged one.
    The value never goes on the wire.
  • OriginatingRequestBinder implements the binding the specification recommends (MRTR, Server Requirements): the principal (iss, client_id/azp, sub, which survive a token refresh between rounds), the method, and the salient parameters from a per-method allowlist (tool or prompt name plus a SHA-256 digest of the canonicalized arguments, or the resource URI).
    An allowlist, as in the Python SDK, so a parameter a later revision adds cannot silently change the binding.
  • RequestStateCodec::withBinding(string) returns a codec with the same key and TTL whose MAC input is body.'.'.binding instead of body.
    The body is base64url and never contains ., so bound and unbound inputs cannot collide.
    StatelessProtocol binds the codec once per request and uses it for verifying the echoed state, for RequestContext::mintRequestState() and for ElicitationReplay.
  • Docs: "Binding the state to the request" in docs/handlers/input-required.md, and the builder method in docs/run/server-builder.md.

Design decisions

  • Opt-in, with the recommended binding shipped.
    Binding by default would reject every state in flight on upgrade and change behaviour for servers that rely on a state verifying across calls.
    Shipping OriginatingRequestBinder keeps opting in to one line, and keeps servers from hand-rolling a stable arguments digest.
  • Interface, not a closure.
    Matches the other builder extension points (SessionLockInterface, NotificationBusInterface); a closure-based binder is a one-class adapter if maintainers prefer callable.
  • Bound to the request as received.
    The binder runs before RequestEvent, so a listener rewriting the request cannot make a retry bind differently from its first round.
    The typed Request carries neither requestState nor inputResponses, which change between rounds; the interface docs warn that _meta (progress token, trace context, capabilities) does too.
  • Modern era only.
    On the handshake era the InputRequiredShim mints and verifies within one request and the state never reaches the client, so there is nothing to replay.
  • Mixed states are refused in both directions.
    A state minted without a binder does not verify on a server with one, and the other way round; otherwise an unbound state would stay replayable across calls after the binder is turned on.
    Turning a binder on therefore fails the calls in flight for at most one TTL, which the builder docblock and the guide state.
  • Same error as a bad MAC.
    A distinct reason would tell a probing client which part of its replay was wrong; it is also indistinguishable internally, since the binding is part of the MAC.

Alternatives considered

  • Binding in the payload (put principal and request identity into the sealed JSON and compare on verify): equivalent security, but grows every state, puts the principal on the wire in clear (the state is signed, not encrypted), and needs a comparison step next to the MAC.
    The TypeScript SDK also stores only a keyed tag; MAC-ing the binding directly needs no tag at all.
  • A separate setRequestStateBinder(): a binder has no effect without the key, so it belongs to the call that configures the key and TTL. The key-ring change to the same method (string or list of keys) composes with the new optional argument.

Backward compatibility

  • No binder configured: wire format, verification and all existing behaviour are unchanged; the existing tests pass untouched.
  • New optional constructor arguments are appended (RequestStateCodec::__construct() third, StatelessProtocol::__construct() last).

Precedents

Why in the SDK

A handler that mints its own state can already bind it by hand: put the principal and an arguments digest into mintRequestState() and compare them on the retry.
It cannot do that for the state the SDK mints itself: ElicitationReplay carries the answers of a handler that calls elicit() more than once, and that state holds only the answers.
A client can echo it on another call of the same tool with other arguments, or under another token, and the handler gets answers that were given for a different request.
Binding in the codec covers both kinds of state in one place, so servers do not each hand-roll it.

Motivation: Shopware (https://github.com/shopware/shopware) plans tools that ask more than once per call on the 2026-07-28 era and needs their state bound to the user and the call (shopware/shopware#22081).

Fixes #623

Test plan

  • RequestStateCodecTest: a bound state verifies under the same binding; is refused (mac) under another; an unbound state is refused by a bound codec and the other way round; the binding stays off the wire, which keeps its two-part shape
  • OriginatingRequestBinderTest: a retry with another id, _meta, argument order and refreshed token binds to the same value; another tool, arguments, nested argument, subject, client, no token, a prompt of the same name or another method binds elsewhere; a resource read binds to its URI
  • StatelessProtocolTest (end to end through Builder): a bound state verifies on a retry of the same call; is refused with -32602/400 on other arguments, another principal or no principal; the binder receives the request and token; a state minted before a binder was configured is refused once one is; without a binder a state still verifies on any call, as before
  • make tests (2972 tests, 8 skipped)
  • make phpstan
  • make cs
  • composer validate --strict

Relation to #626

This PR and #626 (key ring for requestState) change the same setRequestState() signature and codec and compose: withBinding() keeps the whole ring, and the binding is covered by the MAC under every key.
Whichever merges second needs a mechanical conflict resolution, which I have ready and tested.

The 2026-07-28 specification recommends binding a requestState to the
authenticated principal and the originating request. Add
Builder::setRequestStateBinder() and RequestStateBinderInterface: the
binder's value is covered by the state's MAC on mint and recomputed on
verify, so a state echoed on another call or under another principal is
refused like a forged one. OriginatingRequestBinder binds the principal,
the method, the tool, prompt or resource and a digest of the arguments.

Opt-in: without a binder, the wire format and verification are unchanged.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T07:58:07.348871Z 66dc92a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66dc92a2a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +52 to +55
'principal' => null === $accessToken ? null : [
'iss' => \is_string($issuer = $accessToken->getClaim('iss')) ? $issuer : null,
'client' => $accessToken->getClientId(),
'sub' => $accessToken->getSubject(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid collapsing authenticated tokens onto one principal

When two authorized tokens come from the same issuer/client but identify users through a claim other than sub (or carry no standard identity claims), this serializes the same principal for both. AccessToken permits arbitrary claims and JwtTokenValidator::validate() does not require sub or client_id, so for identical method/arguments one user's state can verify under the other's token; fail closed when the standard identity is unavailable or make the stable principal claim/extractor configurable.

Useful? React with 👍 / 👎.

Comment on lines +70 to +74
if (!array_is_list($value)) {
ksort($value, \SORT_STRING);
}

return array_map(self::canonical(...), $value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve object types while canonicalizing arguments

When a nested tool argument is a JSON object with numeric keys out of list order, such as {"1":"one","0":"zero"}, associative decoding makes it a non-list, but sorting it to keys 0,1 and returning an array makes json_encode() serialize it exactly like ["zero","one"]. SchemaValidator::convertDataForValidator() and mixed handlers can distinguish the object and array requests, so a state can verify after a semantically different argument change; retain the JSON container type during canonicalization or fail closed on ambiguous maps.

Useful? React with 👍 / 👎.

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component enhancement Request for a new feature that's not currently supported 2026-07-28 All issues and PRs related to the spec release 2026-07-28 labels Oct 27, 2026
@chr-hertel
chr-hertel requested a balanced review from Copilot October 27, 2026 23:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Argument canonicalization conflates numeric-key objects with lists, allowing argument-binding bypasses, and inconsistently handles object member order, causing valid retries to fail.

2 open issues
What changed in this PR

This PR adds opt-in request-state binding to the PHP SDK’s modern protocol, tying multi-round-trip state to the principal and originating call while preserving existing behavior when no binder is configured.

Changes:

  • Adds a binder interface and a recommended principal-and-request implementation.
  • Applies binding to state verification, handler-minted state, and elicitation replay.
  • Documents configuration and adds codec, binder, and protocol tests.
File Description
tests/​Unit/​Server/​Stateless/​StatelessProtocolTest.php Tests bound retries, replay rejection, and unbound compatibility.
tests/​Unit/​Server/​Stateless/​RequestStateCodecTest.php Tests binding verification and unchanged wire format.
tests/​Unit/​Server/​Stateless/​OriginatingRequestBinderTest.php Tests request and principal identity binding.
src/​Server/​Stateless/​StatelessProtocol.php Uses a request-bound codec for verification and minting.
src/​Server/​Stateless/​RequestStateCodec.php Includes optional binding in the state MAC.
src/​Server/​Stateless/​RequestStateBinderInterface.php Defines the custom binding contract.
src/​Server/​Stateless/​OriginatingRequestBinder.php Implements principal, method, target, and argument binding.
src/​Server/​Builder.php Exposes optional binder configuration.
docs/​run/​server-builder.md Updates the builder example and option reference.
docs/​handlers/​input-required.md Explains binding configuration and migration effects.
CHANGELOG.md Records the new binding feature.

Review effort: Balanced

Comment on lines +70 to +74
if (!array_is_list($value)) {
ksort($value, \SORT_STRING);
}

return array_map(self::canonical(...), $value);
Comment on lines +70 to +71
if (!array_is_list($value)) {
ksort($value, \SORT_STRING);

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2026-07-28 All issues and PRs related to the spec release 2026-07-28 enhancement Request for a new feature that's not currently supported Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind requestState to the principal and the request it was minted for

3 participants