From 55bdbbedb31bd0525f35cd5d502e0a6404aea289 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Mon, 26 Oct 2026 20:31:54 +0100 Subject: [PATCH 1/3] [Server] Bind `requestState` to the request it was minted for 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. --- CHANGELOG.md | 5 + docs/handlers/input-required.md | 25 +++ docs/run/server-builder.md | 6 + src/Server/Builder.php | 22 +++ .../Stateless/OriginatingRequestBinder.php | 76 +++++++++ .../Stateless/RequestStateBinderInterface.php | 44 +++++ src/Server/Stateless/RequestStateCodec.php | 25 ++- src/Server/Stateless/StatelessProtocol.php | 25 ++- .../OriginatingRequestBinderTest.php | 96 +++++++++++ .../Stateless/RequestStateCodecTest.php | 47 +++++ .../Stateless/StatelessProtocolTest.php | 160 +++++++++++++++++- 11 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 src/Server/Stateless/OriginatingRequestBinder.php create mode 100644 src/Server/Stateless/RequestStateBinderInterface.php create mode 100644 tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 468c1e9d5..f68d225a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.9.1 +----- + +* Add `Builder::setRequestStateBinder()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes. + 0.9.0 ----- diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md index 5139e936b..b9a5b6cee 100644 --- a/docs/handlers/input-required.md +++ b/docs/handlers/input-required.md @@ -98,6 +98,31 @@ The **same key must reach every process that might serve the retry**. A per-proc value works only for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. +### Binding the state to the request + +A valid state verifies on any request of the TTL, so a client can echo it on another call, +another tool, or under another user's token. The specification recommends binding it to the +authenticated principal and the originating request. `Builder::setRequestStateBinder()` does +that for the modern era: the binder's value is covered by the MAC of every state the request +mints, and a retry that binds to another value is refused like a forged state. The value +itself never goes on the wire. + +```php +use Mcp\Server\Stateless\OriginatingRequestBinder; + +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +->setRequestStateBinder(new OriginatingRequestBinder()) +``` + +`OriginatingRequestBinder` binds the method, the tool or prompt name and a digest of its +arguments (or the resource URI), and the token's issuer, client and subject. For anything +else, implement `RequestStateBinderInterface`, which receives the request as the client sent +it and its `AccessToken`. Bind only what stays the same across the rounds of one call; +`_meta` does not. + +States minted before the binder was configured no longer verify, so turning it on fails the +calls in flight for at most one TTL. + ## Capabilities A server must not ask for input the client cannot provide. The SDK checks each ask against diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index b1fb6a16b..5550f8b23 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -109,6 +109,7 @@ required to serve either one. Each knob is covered in depth elsewhere: use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Server; +use Mcp\Server\Stateless\OriginatingRequestBinder; use Mcp\Server\Subscription\Psr16NotificationBus; use Mcp\Server\Wire\CachePolicy; @@ -119,6 +120,10 @@ $server = Server::builder() // Without a key, the server refuses every state the client sends back. ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) + // Binds that state to the principal and the call it was minted for, so a + // client cannot echo it on another call or under another token. + ->setRequestStateBinder(new OriginatingRequestBinder()) + // Bounds the input-required shim, which fulfils an `InputRequiredResult` // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. // Defaults to 8 rounds and a 600 second timeout for each input request. @@ -330,6 +335,7 @@ $server = Server::builder() | `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | | `withoutModernEra()` | - | Serve the handshake era only | | `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | +| `setRequestStateBinder()` | binder | Bind request state to the request and principal it was minted for | | `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | | `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | | `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 13f1eb594..52c4ff6a3 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -58,6 +58,7 @@ use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Stateless\RequestStateBinderInterface; use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; @@ -145,6 +146,8 @@ final class Builder private int $requestStateTtl = 600; + private ?RequestStateBinderInterface $requestStateBinder = null; + /** * @var array> */ @@ -385,6 +388,24 @@ public function setRequestState(string $key, int $ttl = 600): self return $this; } + /** + * Binds every `requestState` the modern era mints to the request it was + * minted for, e.g. with {@see Stateless\OriginatingRequestBinder} + * to the principal, method and arguments: a state echoed on a request that + * binds to another value fails verification like a forged one. Has no + * effect without {@see self::setRequestState()}. + * + * States minted before a binder was configured no longer verify, so + * turning one on fails the multi round-trip calls in flight for at most + * the configured TTL. + */ + public function setRequestStateBinder(RequestStateBinderInterface $binder): self + { + $this->requestStateBinder = $binder; + + return $this; + } + /** * Configures the server's pagination limit. */ @@ -1018,6 +1039,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 notificationBus: $this->notificationBus, extensionMethods: $this->extensionMethods, eventDispatcher: $parts['eventDispatcher'], + requestStateBinder: $this->requestStateBinder, ); } diff --git a/src/Server/Stateless/OriginatingRequestBinder.php b/src/Server/Stateless/OriginatingRequestBinder.php new file mode 100644 index 000000000..c9e257c46 --- /dev/null +++ b/src/Server/Stateless/OriginatingRequestBinder.php @@ -0,0 +1,76 @@ + + */ +final class OriginatingRequestBinder implements RequestStateBinderInterface +{ + public function bind(Request $request, ?AccessToken $accessToken): string + { + [$target, $arguments] = match (true) { + $request instanceof CallToolRequest => [$request->name, $request->arguments], + $request instanceof GetPromptRequest => [$request->name, $request->arguments ?? []], + $request instanceof ReadResourceRequest => [$request->uri, []], + default => [null, []], + }; + + return json_encode([ + 'method' => $request::getMethod(), + 'target' => $target, + 'arguments' => hash('sha256', json_encode(self::canonical($arguments), \JSON_THROW_ON_ERROR)), + 'principal' => null === $accessToken ? null : [ + 'iss' => \is_string($issuer = $accessToken->getClaim('iss')) ? $issuer : null, + 'client' => $accessToken->getClientId(), + 'sub' => $accessToken->getSubject(), + ], + ], \JSON_THROW_ON_ERROR); + } + + /** + * Sorts object members recursively, so a client re-encoding the same + * arguments in another member order still binds to the same digest. + */ + private static function canonical(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + + if (!array_is_list($value)) { + ksort($value, \SORT_STRING); + } + + return array_map(self::canonical(...), $value); + } +} diff --git a/src/Server/Stateless/RequestStateBinderInterface.php b/src/Server/Stateless/RequestStateBinderInterface.php new file mode 100644 index 000000000..228910648 --- /dev/null +++ b/src/Server/Stateless/RequestStateBinderInterface.php @@ -0,0 +1,44 @@ + + */ +interface RequestStateBinderInterface +{ + /** + * @param Request $request the request as the client sent it, before any `RequestEvent` listener ran + * @param AccessToken|null $accessToken the token the request was authorized with, null on an unauthorized transport + */ + public function bind(Request $request, ?AccessToken $accessToken): string; +} diff --git a/src/Server/Stateless/RequestStateCodec.php b/src/Server/Stateless/RequestStateCodec.php index 9dcf477bf..e0a9e9ed7 100644 --- a/src/Server/Stateless/RequestStateCodec.php +++ b/src/Server/Stateless/RequestStateCodec.php @@ -23,6 +23,11 @@ * {@see self::verify()} refuses anything whose MAC or expiry does not hold. * Signed, not encrypted: nothing secret belongs in the payload. * + * A codec {@see self::withBinding() bound} to a value also covers that value + * with the MAC, without putting it on the wire: a state minted under one + * binding fails verification under any other, and so does a state minted + * without one. The wire format is the same either way. + * * @author Christopher Hertel */ final class RequestStateCodec @@ -35,6 +40,7 @@ final class RequestStateCodec public function __construct( private readonly string $key, private readonly int $ttlSeconds = 600, + private readonly ?string $binding = null, ) { if (\strlen($this->key) < self::MINIMUM_KEY_BYTES) { throw new InvalidArgumentException(\sprintf('The requestState signing key must be at least %d bytes, got %d.', self::MINIMUM_KEY_BYTES, \strlen($this->key))); @@ -45,6 +51,16 @@ public function __construct( } } + /** + * A codec with the same key and TTL whose states only verify under the + * same binding, e.g. the value a {@see RequestStateBinderInterface} + * computed for the request at hand. + */ + public function withBinding(string $binding): self + { + return new self($this->key, $this->ttlSeconds, $binding); + } + /** * @param array $payload server context to carry to the retry — never secrets */ @@ -61,7 +77,8 @@ public function mint(array $payload, ?int $now = null): string /** * @return array the payload that was sealed * - * @throws RequestStateException when the value is malformed, unsigned by this key, or expired + * @throws RequestStateException when the value is malformed, unsigned by this key, bound to + * something else than this codec, or expired */ public function verify(string $state, ?int $now = null): array { @@ -108,7 +125,11 @@ public function verify(string $state, ?int $now = null): array private function sign(string $body): string { - return hash_hmac(self::ALGORITHM, $body, $this->key, true); + // The body is base64url, so it never contains the '.' separating it + // from the binding: a bound and an unbound input can never collide. + $input = null === $this->binding ? $body : $body.'.'.$this->binding; + + return hash_hmac(self::ALGORITHM, $input, $this->key, true); } private static function encode(string $raw): string diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 53e4c3b4c..9f353edc0 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -114,6 +114,7 @@ public function __construct( private readonly ?NotificationBusInterface $notificationBus = null, private readonly array $extensionMethods = [], private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly ?RequestStateBinderInterface $requestStateBinder = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); @@ -501,8 +502,16 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str $session->set(Protocol::SESSION_LOGGING_LEVEL, $meta->logLevel->value); } + // Bound to the request as it arrived, before a RequestEvent listener + // could change it: the retry has to bind to the same value. + $requestStateCodec = $this->requestStateCodec; + + if (null !== $requestStateCodec && null !== $this->requestStateBinder) { + $requestStateCodec = $requestStateCodec->withBinding($this->requestStateBinder->bind($request, $accessToken)); + } + try { - $input = $this->liftInputContext($decoded['params'] ?? null); + $input = $this->liftInputContext($decoded['params'] ?? null, $requestStateCodec); } catch (RequestStateException $e) { // Invalid params, not an authorization failure: the client only // echoes what it was given, and the reason stays out of the answer. @@ -515,8 +524,8 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str $session->set(InputContext::class, $input); } - if (null !== $this->requestStateCodec) { - $session->set(RequestStateCodec::class, $this->requestStateCodec); + if (null !== $requestStateCodec) { + $session->set(RequestStateCodec::class, $requestStateCodec); } // What ClientGateway::progress() reads to find the progress token, and @@ -674,7 +683,9 @@ private function run(RequestHandlerInterface $handler, Request $request, Session { $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); $input = $session->get(InputContext::class); - $replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $this->requestStateCodec); + // The codec bound to this request, which dispatch() put on the session. + $codec = $session->get(RequestStateCodec::class); + $replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $codec instanceof RequestStateCodec ? $codec : null); $suspended = $fiber->start(); @@ -897,7 +908,7 @@ private function toErrorResult(Request $request, Session $session, string $metho * * @throws RequestStateException when a state is present but does not verify */ - private function liftInputContext(?array $params): ?InputContext + private function liftInputContext(?array $params, ?RequestStateCodec $requestStateCodec): ?InputContext { $responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null; $state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null; @@ -917,11 +928,11 @@ private function liftInputContext(?array $params): ?InputContext if (null !== $state) { // A server with no codec never minted a state, so this one cannot // have come from here. - if (null === $this->requestStateCodec) { + if (null === $requestStateCodec) { throw new RequestStateException('mac'); } - $payload = $this->requestStateCodec->verify($state); + $payload = $requestStateCodec->verify($state); } return new InputContext($responses ?? [], $payload); diff --git a/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php b/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php new file mode 100644 index 000000000..f549bc6b2 --- /dev/null +++ b/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php @@ -0,0 +1,96 @@ + $scopes + */ + private static function token(string $subject = 'alice', string $clientId = 'app', array $scopes = ['read']): AccessToken + { + return new AccessToken($scopes, ['iss' => 'https://auth.example.com', 'client_id' => $clientId, 'sub' => $subject]); + } + + /** + * @param array $arguments + */ + private static function call(string $tool = 'book', array $arguments = ['to' => 'BER', 'seats' => 2]): CallToolRequest + { + return (new CallToolRequest($tool, $arguments))->withId(1); + } + + #[TestDox('a retry of the same call binds to the same value')] + public function testRetryBindsToTheSameValue(): void + { + $binder = new OriginatingRequestBinder(); + + // A retry carries another id, another `_meta`, and a refreshed token. + $first = self::call()->withMeta(['progressToken' => 'p1']); + $retry = (new CallToolRequest('book', ['seats' => 2, 'to' => 'BER']))->withId(2)->withMeta(['progressToken' => 'p2']); + + $this->assertSame( + $binder->bind($first, self::token(scopes: ['read'])), + $binder->bind($retry, self::token(scopes: ['read', 'write'])), + ); + } + + /** + * @return iterable + */ + public static function otherRequests(): iterable + { + yield 'other tool' => [self::call(tool: 'cancel'), self::token()]; + yield 'other arguments' => [self::call(arguments: ['to' => 'CDG', 'seats' => 2]), self::token()]; + yield 'nested arguments differ' => [self::call(arguments: ['to' => 'BER', 'seats' => [2]]), self::token()]; + yield 'other subject' => [self::call(), self::token(subject: 'mallory')]; + yield 'other client' => [self::call(), self::token(clientId: 'other-app')]; + yield 'no token' => [self::call(), null]; + yield 'prompt of the same name' => [(new GetPromptRequest('book', ['to' => 'BER', 'seats' => '2']))->withId(1), self::token()]; + yield 'other method' => [(new ListToolsRequest())->withId(1), self::token()]; + } + + #[DataProvider('otherRequests')] + #[TestDox('another request or principal binds to another value: $_dataName')] + public function testOtherRequestsBindElsewhere(Request $request, ?AccessToken $token): void + { + $binder = new OriginatingRequestBinder(); + + $this->assertNotSame($binder->bind(self::call(), self::token()), $binder->bind($request, $token)); + } + + #[TestDox('a resource read binds to its URI')] + public function testResourceReadBindsToItsUri(): void + { + $binder = new OriginatingRequestBinder(); + + $this->assertSame( + $binder->bind((new ReadResourceRequest('file:///a'))->withId(1), null), + $binder->bind((new ReadResourceRequest('file:///a'))->withId(2), null), + ); + $this->assertNotSame( + $binder->bind((new ReadResourceRequest('file:///a'))->withId(1), null), + $binder->bind((new ReadResourceRequest('file:///b'))->withId(1), null), + ); + } +} diff --git a/tests/Unit/Server/Stateless/RequestStateCodecTest.php b/tests/Unit/Server/Stateless/RequestStateCodecTest.php index 6b84b8f98..db6386dc4 100644 --- a/tests/Unit/Server/Stateless/RequestStateCodecTest.php +++ b/tests/Unit/Server/Stateless/RequestStateCodecTest.php @@ -112,6 +112,53 @@ public function testFailureReasonsAreOpaque(): void } } + #[TestDox('a bound state verifies under the same binding')] + public function testBoundRoundTrip(): void + { + $codec = (new RequestStateCodec(self::KEY))->withBinding('tools/call|book'); + + $this->assertSame(['round' => 1], $codec->withBinding('tools/call|book')->verify($codec->mint(['round' => 1]))); + } + + #[TestDox('a bound state is refused under another binding, like a forged one')] + public function testBoundStateRefusedUnderAnotherBinding(): void + { + $codec = new RequestStateCodec(self::KEY); + $state = $codec->withBinding('tools/call|book')->mint(['round' => 1]); + + $this->expectExceptionObject(new RequestStateException('mac')); + $codec->withBinding('tools/call|cancel')->verify($state); + } + + #[TestDox('an unbound state is refused by a bound codec, and a bound one by an unbound codec')] + public function testBindingIsNeitherAddedNorStripped(): void + { + $unbound = new RequestStateCodec(self::KEY); + $bound = $unbound->withBinding(''); + + foreach ([[$unbound, $bound], [$bound, $unbound]] as [$minter, $verifier]) { + try { + $verifier->verify($minter->mint(['round' => 1])); + $this->fail('Expected rejection.'); + } catch (RequestStateException $e) { + $this->assertSame('mac', $e->getMessage()); + } + } + } + + #[TestDox('the binding stays off the wire, which keeps its shape')] + public function testBindingStaysOffTheWire(): void + { + $state = (new RequestStateCodec(self::KEY))->withBinding('alice@example.com')->mint(['round' => 1], now: 1_000); + [$body] = explode('.', $state); + + $this->assertCount(2, explode('.', $state)); + $this->assertSame( + ['exp' => 1_600, 'data' => ['round' => 1]], + json_decode(base64_decode(strtr($body, '-_', '+/')), true), + ); + } + #[TestDox('a key too short to be safe is refused at construction')] public function testShortKeyRefused(): void { diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 5938667cc..36f6f200c 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -23,6 +23,7 @@ use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\Notification\PromptListChangedNotification; use Mcp\Schema\Notification\ResourceUpdatedNotification; use Mcp\Schema\Notification\ToolListChangedNotification; @@ -33,8 +34,11 @@ use Mcp\Schema\Result\ReadResourceResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server; +use Mcp\Server\Authorization\AccessToken; use Mcp\Server\RequestContext; +use Mcp\Server\Stateless\OriginatingRequestBinder; use Mcp\Server\Stateless\RequestMeta; +use Mcp\Server\Stateless\RequestStateBinderInterface; use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Stateless\StatelessResult; use Mcp\Server\Subscription\InMemoryNotificationBus; @@ -577,7 +581,7 @@ public function testUndeclaredGatewayElicitationIsRefused(): void * A handler asking twice: the second round has to remember the first * round's answer, which is what the `requestState` is for. */ - private static function twoAskProtocol(bool $signed = true): StatelessProtocol + private static function twoAskProtocol(bool $signed = true, ?RequestStateBinderInterface $binder = null): StatelessProtocol { $builder = Server::builder() ->setServerInfo('test-server', '1.0.0') @@ -604,6 +608,10 @@ static function (RequestContext $context): string { $builder->setRequestState(str_repeat('k', 32)); } + if (null !== $binder) { + $builder->setRequestStateBinder($binder); + } + return $builder->buildStateless([ProtocolVersion::V2026_07_28]); } @@ -650,6 +658,156 @@ public function testNamedAsksCarryAcrossRounds(): void $this->assertSame('morning/aisle', $third['body']['result']['content'][0]['text']); } + /** + * A {@see self::book()} that can carry other arguments and an access token. + * + * @param array $params + * @param array $arguments + * + * @return array + */ + private static function bookAs(StatelessProtocol $protocol, ?AccessToken $token, array $params = [], array $arguments = []): array + { + $result = $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'books_flight', + 'arguments' => (object) $arguments, + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => ['elicitation' => new \stdClass()], + ], + ...$params, + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'books_flight', + ], $token); + + return [ + 'status' => $result->httpStatus, + 'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR), + ]; + } + + private static function alice(string $subject = 'alice'): AccessToken + { + return new AccessToken(['flights'], ['iss' => 'https://auth.example.com', 'client_id' => 'app', 'sub' => $subject]); + } + + /** + * The second round of a {@see self::twoAskProtocol()} call, which mints + * the state carrying the first answer. + */ + private function mintedState(StatelessProtocol $protocol, ?AccessToken $token, array $arguments = []): string + { + $round = self::bookAs($protocol, $token, [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ], $arguments); + + $this->assertIsString($round['body']['result']['requestState'] ?? null); + + return $round['body']['result']['requestState']; + } + + #[TestDox('a bound state verifies on a retry of the same call by the same principal')] + public function testBoundStateVerifiesOnTheSameCall(): void + { + $protocol = self::twoAskProtocol(binder: new OriginatingRequestBinder()); + $state = $this->mintedState($protocol, self::alice(), ['to' => 'BER']); + + $answer = self::bookAs($protocol, self::alice(), [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $state, + ], ['to' => 'BER']); + + $this->assertSame(200, $answer['status']); + $this->assertSame('morning/aisle', $answer['body']['result']['content'][0]['text']); + } + + /** + * @return iterable, ?AccessToken}> + */ + public static function otherCalls(): iterable + { + yield 'other arguments' => [['to' => 'CDG'], self::alice()]; + yield 'other principal' => [['to' => 'BER'], self::alice('mallory')]; + yield 'no principal' => [['to' => 'BER'], null]; + } + + /** + * @param array $arguments + */ + #[DataProvider('otherCalls')] + #[TestDox('a bound state echoed on another call is refused like a forged one: $_dataName')] + public function testBoundStateRefusedOnAnotherCall(array $arguments, ?AccessToken $token): void + { + $protocol = self::twoAskProtocol(binder: new OriginatingRequestBinder()); + $state = $this->mintedState($protocol, self::alice(), ['to' => 'BER']); + + $answer = self::bookAs($protocol, $token, [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $state, + ], $arguments); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + $this->assertSame('The supplied requestState failed verification.', $answer['body']['error']['message']); + } + + #[TestDox('the binder sees the request as sent and its access token')] + public function testBinderSeesTheRequestAndToken(): void + { + $binder = new class implements RequestStateBinderInterface { + /** @var list, ?string}> */ + public array $calls = []; + + public function bind(Request $request, ?AccessToken $accessToken): string + { + \assert($request instanceof CallToolRequest); + $this->calls[] = [$request->name, $request->arguments, $accessToken?->getSubject()]; + + return 'fixed'; + } + }; + + $this->mintedState(self::twoAskProtocol(binder: $binder), self::alice(), ['to' => 'BER']); + + $this->assertSame([['books_flight', ['to' => 'BER'], 'alice']], $binder->calls); + } + + #[TestDox('a state minted before a binder was configured is refused once one is')] + public function testUnboundStateRefusedByABindingServer(): void + { + $state = $this->mintedState(self::twoAskProtocol(), self::alice()); + + $answer = self::bookAs(self::twoAskProtocol(binder: new OriginatingRequestBinder()), self::alice(), [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $state, + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + } + + #[TestDox('without a binder, a state verifies on any call, as before')] + public function testUnboundStateIsNotTiedToTheCall(): void + { + $protocol = self::twoAskProtocol(); + $state = $this->mintedState($protocol, self::alice(), ['to' => 'BER']); + + $answer = self::bookAs($protocol, self::alice('mallory'), [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $state, + ], ['to' => 'CDG']); + + $this->assertSame(200, $answer['status']); + $this->assertSame('morning/aisle', $answer['body']['result']['content'][0]['text']); + } + #[TestDox('carrying an answer to the next round without a signing key fails loudly')] public function testCarryingAnAnswerNeedsASigningKey(): void { From 7a9ca0992de9677a5be01073ec42363fe9787cfc Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Tue, 27 Oct 2026 08:34:39 +0100 Subject: [PATCH 2/3] [Server] Take the `requestState` binder as an argument of `setRequestState()` --- CHANGELOG.md | 2 +- docs/handlers/input-required.md | 7 ++--- docs/run/server-builder.md | 11 +++---- src/Server/Builder.php | 31 +++++++------------ .../Stateless/OriginatingRequestBinder.php | 2 +- .../Stateless/RequestStateBinderInterface.php | 2 +- .../Stateless/StatelessProtocolTest.php | 6 +--- 7 files changed, 22 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f68d225a9..fd0c082d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.9.1 ----- -* Add `Builder::setRequestStateBinder()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes. +* Add a `binder` argument to `Builder::setRequestState()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes. 0.9.0 ----- diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md index b9a5b6cee..78a97b51d 100644 --- a/docs/handlers/input-required.md +++ b/docs/handlers/input-required.md @@ -102,16 +102,15 @@ it is signed, not encrypted. A valid state verifies on any request of the TTL, so a client can echo it on another call, another tool, or under another user's token. The specification recommends binding it to the -authenticated principal and the originating request. `Builder::setRequestStateBinder()` does -that for the modern era: the binder's value is covered by the MAC of every state the request +authenticated principal and the originating request. The `binder` argument of +`Builder::setRequestState()` does that for the modern era: the binder's value is covered by the MAC of every state the request mints, and a retry that binds to another value is refused like a forged state. The value itself never goes on the wire. ```php use Mcp\Server\Stateless\OriginatingRequestBinder; -->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) -->setRequestStateBinder(new OriginatingRequestBinder()) +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600, binder: new OriginatingRequestBinder()) ``` `OriginatingRequestBinder` binds the method, the tool or prompt name and a digest of its diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 5550f8b23..ff401b8a5 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -118,11 +118,9 @@ $server = Server::builder() // The same key must reach every process that might serve the retry. // The key needs at least 32 bytes, otherwise build() throws. TTL in seconds, default 600. // Without a key, the server refuses every state the client sends back. - ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) - - // Binds that state to the principal and the call it was minted for, so a - // client cannot echo it on another call or under another token. - ->setRequestStateBinder(new OriginatingRequestBinder()) + // The optional binder ties each state to the principal and the call it was + // minted for, so a client cannot echo it on another call or under another token. + ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600, binder: new OriginatingRequestBinder()) // Bounds the input-required shim, which fulfils an `InputRequiredResult` // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. @@ -334,8 +332,7 @@ $server = Server::builder() | `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | | `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | | `withoutModernEra()` | - | Serve the handshake era only | -| `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | -| `setRequestStateBinder()` | binder | Bind request state to the request and principal it was minted for | +| `setRequestState()` | key, ttl?, binder? | Signing key, lifetime and optional binding to the originating request for multi round-trip request state | | `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | | `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | | `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 52c4ff6a3..76739ceff 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -377,30 +377,21 @@ public function setCachePolicy(CachePolicy $policy): self * The same key must reach every instance that might serve the retry, so a * per-process random value only works for a single-process deployment. * - * @param string $key at least 32 bytes - * @param int $ttl how long a minted state stays valid, in seconds + * A binder ties every state the modern era mints to the request it was + * minted for, e.g. {@see Stateless\OriginatingRequestBinder} to the + * principal, method and arguments: a state echoed on a request that binds + * to another value fails verification like a forged one. States minted + * before a binder was configured no longer verify, so adding one fails the + * multi round-trip calls in flight for at most the TTL. + * + * @param string $key at least 32 bytes + * @param int $ttl how long a minted state stays valid, in seconds + * @param RequestStateBinderInterface|null $binder binds each state to its originating request */ - public function setRequestState(string $key, int $ttl = 600): self + public function setRequestState(string $key, int $ttl = 600, ?RequestStateBinderInterface $binder = null): self { $this->requestStateKey = $key; $this->requestStateTtl = $ttl; - - return $this; - } - - /** - * Binds every `requestState` the modern era mints to the request it was - * minted for, e.g. with {@see Stateless\OriginatingRequestBinder} - * to the principal, method and arguments: a state echoed on a request that - * binds to another value fails verification like a forged one. Has no - * effect without {@see self::setRequestState()}. - * - * States minted before a binder was configured no longer verify, so - * turning one on fails the multi round-trip calls in flight for at most - * the configured TTL. - */ - public function setRequestStateBinder(RequestStateBinderInterface $binder): self - { $this->requestStateBinder = $binder; return $this; diff --git a/src/Server/Stateless/OriginatingRequestBinder.php b/src/Server/Stateless/OriginatingRequestBinder.php index c9e257c46..938f9ddd1 100644 --- a/src/Server/Stateless/OriginatingRequestBinder.php +++ b/src/Server/Stateless/OriginatingRequestBinder.php @@ -32,7 +32,7 @@ * * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow * - * @author Björn Meyer + * @author Björn Meyer */ final class OriginatingRequestBinder implements RequestStateBinderInterface { diff --git a/src/Server/Stateless/RequestStateBinderInterface.php b/src/Server/Stateless/RequestStateBinderInterface.php index 228910648..6fc42abc0 100644 --- a/src/Server/Stateless/RequestStateBinderInterface.php +++ b/src/Server/Stateless/RequestStateBinderInterface.php @@ -32,7 +32,7 @@ * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow * @see OriginatingRequestBinder for the binding the specification recommends * - * @author Björn Meyer + * @author Björn Meyer */ interface RequestStateBinderInterface { diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 36f6f200c..1d3f8e962 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -605,11 +605,7 @@ static function (RequestContext $context): string { ); if ($signed) { - $builder->setRequestState(str_repeat('k', 32)); - } - - if (null !== $binder) { - $builder->setRequestStateBinder($binder); + $builder->setRequestState(str_repeat('k', 32), binder: $binder); } return $builder->buildStateless([ProtocolVersion::V2026_07_28]); From 66dc92a2a81653b738dbe5e517d312b19cd0ac73 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Tue, 27 Oct 2026 08:41:06 +0100 Subject: [PATCH 3/3] [Docs] Explain which `requestState` only the binder can bind --- docs/handlers/input-required.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md index 78a97b51d..8fe74f5c9 100644 --- a/docs/handlers/input-required.md +++ b/docs/handlers/input-required.md @@ -101,11 +101,16 @@ it is signed, not encrypted. ### Binding the state to the request A valid state verifies on any request of the TTL, so a client can echo it on another call, -another tool, or under another user's token. The specification recommends binding it to the -authenticated principal and the originating request. The `binder` argument of -`Builder::setRequestState()` does that for the modern era: the binder's value is covered by the MAC of every state the request -mints, and a retry that binds to another value is refused like a forged state. The value -itself never goes on the wire. +another tool, or under another user's token. The +[specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow) +recommends binding it to the authenticated principal and the originating request. + +A handler that mints its own state can put that identity into the payload and compare it on +the retry. It cannot do so for the state the SDK mints when it carries answers to the next +round, as for a handler that calls `elicit()` twice. The `binder` argument of +`Builder::setRequestState()` binds both on the modern era: the binder's value is covered by +the MAC of every state the request mints, and a retry that binds to another value is refused +like a forged state. The value itself never goes on the wire. ```php use Mcp\Server\Stateless\OriginatingRequestBinder;