Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix a nullable tool parameter with a default (e.g. `?string $category = 'books'`) receiving the default when the client explicitly sends `null`: `ReferenceHandler` now passes the `null` through.
* Fix a `subscriptions/listen` stream that can carry nothing (the server announces none of the requested types, or the client asks for none) being held open for the full subscription lifetime: it is acknowledged with an empty `notifications` filter and closed gracefully right away, instead of pinning a PHP-FPM worker. Adds `NotificationFilter::isEmpty()`.
* [BC Break] Answer `server/discover` with `DiscoverHandler`, which a handler added through `Builder::addRequestHandler()` can replace, and dispatch `RequestEvent`, `ResponseEvent` and `ErrorEvent` for it. A `StatelessProtocol` built without the `Builder` needs `DiscoverHandler` and a `MessageFactory` with `DiscoverRequest` to answer it.
* 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; a token without a subject fails closed, and a `subject` resolver identifies users by another claim. `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes.

### Client

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

A token without a subject (`sub`) identifies no user, so the binder cannot tell two users of
one client apart. It fails closed: such a request binds to a value of its own, and a state it
mints is refused on the retry. If your authorization server identifies users by another claim,
pass a resolver that returns a value stable across a token refresh, or `null` for a token that
identifies nobody. The issuer and client stay bound either way:

```php
use Mcp\Server\Authorization\AccessToken;

new OriginatingRequestBinder(
subject: static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
)
```

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 @@ -333,7 +336,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 @@ -59,6 +59,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 @@ -148,6 +149,8 @@ final class Builder

private int $requestStateTtl = 600;

private ?RequestStateBinderInterface $requestStateBinder = null;

/**
* @var array<int, RequestHandlerInterface<mixed>>
*/
Expand Down Expand Up @@ -378,13 +381,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 @@ -1026,6 +1038,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
eventDispatcher: $parts['eventDispatcher'],
requestStateBinder: $this->requestStateBinder,
);
}

Expand Down
140 changes: 140 additions & 0 deletions src/Server/Stateless/OriginatingRequestBinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?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\Exception\RuntimeException;
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.
*
* A token without a subject identifies no user: tokens of different users of
* one client would share a principal. Such a request binds to a value of its
* own, so the states it mints never verify. Pass a `$subject` resolver when
* the authorization server identifies users by another claim; the issuer
* and client stay part of the principal either way.
*
* @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
{
// A canonical map nests three levels deep, and requests decode up to json_decode()'s default of 512.
private const ENCODE_DEPTH = 3 * 512;

/**
* @param (\Closure(AccessToken): ?string)|null $subject identifies the user a token was issued for, stable
* across a token refresh; null when the token identifies
* nobody. Defaults to the `sub` claim.
*/
public function __construct(
private readonly ?\Closure $subject = null,
) {
}

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 self::encode([
'method' => $request::getMethod(),
'target' => $target,
'arguments' => hash('sha256', self::encode(self::canonical($arguments))),
'principal' => null === $accessToken ? null : $this->principal($accessToken),
]);
}

/**
* @return array{identified: array{iss: ?string, client: ?string, sub: string}}|array{unidentified: string}
*/
private function principal(AccessToken $accessToken): array
{
$subject = null === $this->subject ? $accessToken->getSubject() : ($this->subject)($accessToken);

if (null === $subject) {
return ['unidentified' => bin2hex(random_bytes(16))];
}

$issuer = $accessToken->getClaim('iss');

// Not up to the resolver: a state stays with the issuer and client it was minted for.
return ['identified' => [
'iss' => \is_string($issuer) ? $issuer : null,
'client' => $accessToken->getClientId(),
'sub' => $subject,
]];
}

private static function encode(mixed $value): string
{
try {
return json_encode($value, \JSON_THROW_ON_ERROR, self::ENCODE_DEPTH);
} catch (\JsonException $e) {
throw new RuntimeException(\sprintf('Failed to encode the requestState binding as JSON: %s', $e->getMessage()), previous: $e);
}
}

/**
* Sorts object members recursively, so a client re-encoding the same
* arguments in another member order still binds to the same digest.
*
* A list stays a JSON array and any other array becomes sorted pairs
* under `{"map": [[key, value], ...]}`, the same split schema validation
* and the handler see: a map with numeric keys out of order, e.g.
* `{"1": "B", "0": "A"}`, must not sort into the list `["A", "B"]`. Not
* cast to an object, as `json_encode()` drops object members whose name
* starts with NUL. The arguments are decoded into PHP arrays,
* so a numeric-key object whose members arrive in list order is a list
* to the whole SDK and binds as one. Numbers bind by value: `1` and `1.0`
* encode alike, so a client re-encoding a number does not break its retry.
*/
private static function canonical(mixed $value): mixed
{
if (!\is_array($value)) {
return $value;
}

if (array_is_list($value)) {
return array_map(self::canonical(...), $value);
}

ksort($value, \SORT_STRING);

$members = [];

foreach ($value as $key => $member) {
$members[] = [(string) $key, self::canonical($member)];
}

return ['map' => $members];
}
}
45 changes: 45 additions & 0 deletions src/Server/Stateless/RequestStateBinderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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. A binder that throws fails
* the request with an internal error.
*
* 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
Loading
Loading