Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.9.1
-----

* 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
-----

Expand Down
29 changes: 29 additions & 0 deletions docs/handlers/input-required.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ 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](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;

->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
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
Expand Down
7 changes: 5 additions & 2 deletions docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -117,7 +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)
// 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.
Expand Down Expand Up @@ -329,7 +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 |
| `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 |
Expand Down
19 changes: 16 additions & 3 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,6 +146,8 @@ final class Builder

private int $requestStateTtl = 600;

private ?RequestStateBinderInterface $requestStateBinder = null;

/**
* @var array<int, RequestHandlerInterface<mixed>>
*/
Expand Down Expand Up @@ -374,13 +377,22 @@ 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;
$this->requestStateBinder = $binder;

return $this;
}
Expand Down Expand Up @@ -1018,6 +1030,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
eventDispatcher: $parts['eventDispatcher'],
requestStateBinder: $this->requestStateBinder,
);
}

Expand Down
76 changes: 76 additions & 0 deletions src/Server/Stateless/OriginatingRequestBinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Server\Stateless;

use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Server\Authorization\AccessToken;

/**
* Binds a `requestState` to what the specification recommends: the
* authenticated principal and the originating request, as its method name
* plus a digest of its salient parameters.
*
* The salient parameters are an allowlist per method, so a parameter a later
* revision adds cannot silently change the binding: the tool or prompt name
* and a digest of its arguments, or the resource URI. Any other method binds
* by its name alone. The principal is the token's issuer, client and subject,
* which outlive a token refresh between two rounds; an unauthorized request
* binds to no principal, so its state does not verify on an authorized one,
* nor the other way round.
*
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
*
* @author Björn Meyer <b.meyer@shopware.com>
*/
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(),
Comment on lines +52 to +55

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 👍 / 👎.

],
], \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);
Comment on lines +70 to +71
}

return array_map(self::canonical(...), $value);
Comment on lines +70 to +74

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 👍 / 👎.

Comment on lines +70 to +74
}
}
44 changes: 44 additions & 0 deletions src/Server/Stateless/RequestStateBinderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Server\Stateless;

use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\Authorization\AccessToken;

/**
* Binds the `requestState` of a multi round-trip request to the request it
* was minted for.
*
* The SDK calls {@see self::bind()} once per request: the value it returns is
* covered by the MAC of every state minted while answering the request, and
* recomputed to verify the state the client echoes on the retry. A state
* presented on a request that binds to another value fails verification like
* a forged one. The value never goes on the wire.
*
* Bind only what stays the same across the rounds of one call: the request
* object carries neither `requestState` nor `inputResponses`, but `_meta`
* (progress token, trace context, client capabilities) may change between
* rounds and does not belong in the binding.
*
* @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 <b.meyer@shopware.com>
*/
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;
}
25 changes: 23 additions & 2 deletions src/Server/Stateless/RequestStateCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mail@christopher-hertel.de>
*/
final class RequestStateCodec
Expand All @@ -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)));
Expand All @@ -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<string, mixed> $payload server context to carry to the retry — never secrets
*/
Expand All @@ -61,7 +77,8 @@ public function mint(array $payload, ?int $now = null): string
/**
* @return array<string, mixed> 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
{
Expand Down Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions src/Server/Stateless/StatelessProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Loading
Loading