[Server][Tests] Complete the multi round-trip request flow (SEP-2322) (#446)

The revision has no way for a server to put a request on a response stream,
so a handler that needs input from the user says so in its result instead: a
handler returning InputRequiredResult comes back as
resultType: "input_required" with the asks and an opaque requestState, and
the client retries the same request carrying inputResponses.

RequestContext::getInputContext() is what the handler reads them through.
RequestStateCodec signs and time-bounds the state, so a tampered or expired
one is refused rather than trusted - the state travels through the client,
which is not a place to keep something the server later believes.
This commit is contained in:
Christopher Hertel 2026-08-18 22:45:35 +02:00 • committed by GitHub
commit ef0c1c2808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 906 additions and 341 deletions

View file

@ -0,0 +1,63 @@
<?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;
/**
* What a retry brought back: the client's answers to a previous
* {@see \Mcp\Schema\Result\InputRequiredResult}, keyed as its `inputRequests`
* were, plus the verified state that result carried.
*
* Absent on a first call, which is how a handler tells the rounds apart.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class InputContext
{
/**
* @param array<string, mixed> $responses client answers, keyed as the inputRequests were
* @param array<string, mixed> $requestState the verified payload the server sealed last round
*/
public function __construct(
private readonly array $responses = [],
private readonly array $requestState = [],
) {
}
/**
* The client's answer for `$key`, or null when it did not provide one.
*/
public function response(string $key): mixed
{
return $this->responses[$key] ?? null;
}
public function has(string $key): bool
{
return \array_key_exists($key, $this->responses);
}
/**
* @return array<string, mixed>
*/
public function all(): array
{
return $this->responses;
}
/**
* @return array<string, mixed>
*/
public function requestState(): array
{
return $this->requestState;
}
}

View file

@ -16,10 +16,8 @@ use Mcp\Schema\ServerCapabilities;
/**
* Which notification types a `subscriptions/listen` stream will carry.
*
* The filter is an allow-list, not a hint: "The server MUST NOT send
* notification types the client has not explicitly requested." Omitting a
* field is the same as declining that type, so absence and `false` mean the
* same thing here and the type never needs to distinguish them.
* An allow-list, not a hint: a server MUST NOT send types the client did not
* request, and an omitted field declines just as `false` does.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
@ -54,13 +52,8 @@ final class NotificationFilter
}
/**
* Narrows the filter to what this server can actually deliver.
*
* The acknowledgment must reflect "the subset the server agreed to honor",
* so a type the server does not support is dropped here rather than
* acknowledged and then silently never sent — a client comparing the
* acknowledgment against its request would otherwise wait forever for
* notifications that were never coming.
* Narrows the filter to what this server can deliver, so the acknowledgment
* reflects the subset it agreed to honour rather than promising silence.
*/
public function intersect(ServerCapabilities $capabilities): self
{
@ -73,8 +66,7 @@ final class NotificationFilter
}
/**
* The acknowledgment's `notifications` member: agreed types only, with
* declined ones omitted rather than reported as `false`.
* Agreed types only; declined ones are omitted rather than sent as `false`.
*
* @return array<string, mixed>
*/

View file

@ -20,15 +20,11 @@ use Mcp\Schema\Implementation;
* The per-request metadata that replaces the `initialize` handshake in the
* modern era (SEP-2575).
*
* A handshake-era connection learns the protocol version, the client's
* identity and its capabilities once, then keeps them in session state. With
* no session to keep them in, every modern request restates them in
* `params._meta` — so this object is the stateless equivalent of the session
* a {@see \Mcp\Server\Protocol} run would have resolved.
* `protocolVersion` and `clientCapabilities` are structurally required; a
* server cannot decide how to answer without them. `clientInfo` is a SHOULD,
* and a request omitting it MUST NOT be refused.
*
* `protocolVersion` and `clientCapabilities` are structurally required: a
* server cannot decide how to answer without them. `clientInfo` is not — it is
* a SHOULD, and servers MUST NOT refuse a request that omits it.
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class RequestMeta
{
@ -65,9 +61,7 @@ final class RequestMeta
throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::PROTOCOL_VERSION));
}
// Required, but an empty object is a perfectly valid value: it declares
// a client that supports none of the optional capabilities. Only its
// absence — or a non-object — is a protocol error.
// An empty object is valid — a client with no optional capabilities.
$capabilities = $meta[self::CLIENT_CAPABILITIES] ?? null;
if (!\is_array($capabilities) && !$capabilities instanceof \stdClass) {
throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::CLIENT_CAPABILITIES));
@ -84,9 +78,8 @@ final class RequestMeta
}
/**
* An unparseable level is treated as "no level requested" rather than as a
* protocol error: log verbosity is a diagnostic preference, and rejecting
* the whole request over it would fail the caller's actual work.
* An unparseable level means "none requested": failing the caller's actual
* work over a diagnostic preference would be worse.
*/
private static function parseLogLevel(mixed $level): ?LoggingLevel
{

View file

@ -0,0 +1,125 @@
<?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\InvalidArgumentException;
use Mcp\Exception\RequestStateException;
/**
* Seals and verifies the opaque `requestState` carried across the rounds of a
* multi round-trip request (SEP-2322).
*
* The value is `base64url(payload).base64url(HMAC)`. It passes through the
* client, so it is attacker-controlled on return and
* {@see self::verify()} refuses anything whose MAC or expiry does not hold.
* Signed, not encrypted: nothing secret belongs in the payload.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class RequestStateCodec
{
/** Below this the MAC — the only thing making the blob trustworthy — is forgeable. */
public const MINIMUM_KEY_BYTES = 32;
private const ALGORITHM = 'sha256';
public function __construct(
private readonly string $key,
private readonly int $ttlSeconds = 600,
) {
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)));
}
if ($this->ttlSeconds < 1) {
throw new InvalidArgumentException(\sprintf('The requestState TTL must be at least one second, got %d.', $this->ttlSeconds));
}
}
/**
* @param array<string, mixed> $payload server context to carry to the retry — never secrets
*/
public function mint(array $payload, ?int $now = null): string
{
$body = self::encode(json_encode([
'exp' => ($now ?? time()) + $this->ttlSeconds,
'data' => $payload,
], \JSON_THROW_ON_ERROR));
return $body.'.'.self::encode($this->sign($body));
}
/**
* @return array<string, mixed> the payload that was sealed
*
* @throws RequestStateException when the value is malformed, unsigned by this key, or expired
*/
public function verify(string $state, ?int $now = null): array
{
$parts = explode('.', $state);
if (2 !== \count($parts)) {
throw new RequestStateException('malformed');
}
[$body, $mac] = $parts;
$expected = $this->sign($body);
$actual = self::decode($mac);
// Constant-time: a short-circuiting compare is a byte-at-a-time oracle.
if (null === $actual || !hash_equals($expected, $actual)) {
throw new RequestStateException('mac');
}
$decoded = self::decode($body);
if (null === $decoded) {
throw new RequestStateException('malformed');
}
try {
/** @var array<string, mixed> $claims */
$claims = json_decode($decoded, true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new RequestStateException('malformed');
}
if (!\is_array($claims) || !\is_int($claims['exp'] ?? null) || !\is_array($claims['data'] ?? null)) {
throw new RequestStateException('malformed');
}
// Bounds replay; does not make the state single-use.
if ($claims['exp'] < ($now ?? time())) {
throw new RequestStateException('expired');
}
return $claims['data'];
}
private function sign(string $body): string
{
return hash_hmac(self::ALGORITHM, $body, $this->key, true);
}
private static function encode(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
private static function decode(string $encoded): ?string
{
$decoded = base64_decode(strtr($encoded, '-_', '+/'), true);
return false === $decoded ? null : $decoded;
}
}

View file

@ -17,13 +17,9 @@ use Mcp\Exception\ToolNotFoundException;
/**
* Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243).
*
* The headers exist so an intermediary — a gateway, a proxy, a rate limiter —
* can route and police MCP traffic without parsing the body. That only works
* if the two cannot disagree, so a server that *does* read the body is
* required to confirm they match and to reject the request when they do not.
* Otherwise a caller could show a gateway one request and the server another.
*
* Failures are all one kind: HTTP 400 with JSON-RPC `-32020`.
* The headers let an intermediary route MCP traffic without parsing the body,
* which only holds if the two cannot disagree — otherwise a caller could show
* a gateway one request and the server another.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
@ -74,10 +70,8 @@ final class StandardHeaderValidator
}
/**
* `Mcp-Name` names whatever the request acts on, which is a different
* member per method. When the body carries one, the header must repeat it;
* when it does not, the header has nothing to agree with and the server
* must not demand one.
* When the body carries a name the header must repeat it; when it does not,
* the server must not demand one.
*
* @param array<string, mixed>|null $params
* @param array<string, string> $headers
@ -103,8 +97,7 @@ final class StandardHeaderValidator
}
/**
* The subject of a request, per method. Anything not listed carries no
* name, and so is exempt from the header entirely.
* The subject of a request, per method. Anything unlisted is exempt.
*
* @param array<string, mixed>|null $params
*/
@ -121,13 +114,9 @@ final class StandardHeaderValidator
}
/**
* Tool arguments a tool marked with `x-mcp-header` are mirrored into
* `Mcp-Param-*` headers, and must survive the round trip unchanged.
*
* Only headers the *tool* declares are checked. An unrecognized
* `Mcp-Param-*` belongs to somebody else in the chain — the spec has
* intermediaries forward what they do not understand — so rejecting it
* would break traffic this server has no stake in.
* Only headers the tool itself declares are checked: an unrecognized
* `Mcp-Param-*` belongs to somebody else in the chain, and intermediaries
* are meant to forward what they do not understand.
*
* @param array<string, mixed>|null $params
* @param array<string, string> $headers
@ -146,8 +135,7 @@ final class StandardHeaderValidator
try {
$tool = $this->registry->getTool($toolName)->tool;
} catch (ToolNotFoundException) {
// Not this validator's error to report: the handler will answer
// with a proper tool-not-found rather than a header complaint.
// The handler reports this properly; a header complaint would not.
return null;
}
@ -184,8 +172,7 @@ final class StandardHeaderValidator
{
$declared = $this->header($headers, $headerName);
// A null or omitted argument means the client omits the header, and the
// server must not expect one.
// An omitted argument means an omitted header.
if (null === $argument) {
return null;
}
@ -200,9 +187,7 @@ final class StandardHeaderValidator
return \sprintf('%s header is not a well-formed Base64 wrapper.', $headerName);
}
// Numbers travel as their decimal string, booleans as "true"/"false" —
// so the comparison is between the decoded header and the argument
// rendered the same way, not between PHP values.
// Numbers travel as decimal strings, booleans as "true"/"false".
$expected = match (true) {
\is_bool($argument) => $argument ? 'true' : 'false',
\is_scalar($argument) => (string) $argument,
@ -223,11 +208,9 @@ final class StandardHeaderValidator
/**
* Unwraps a `=?base64?…?=` value, or returns a plain value unchanged.
*
* Decoding is strict. PHP's decoder is happy to accept mispadded or
* out-of-alphabet input and hand back plausible-looking bytes, which would
* turn a corrupted header into a silent mismatch — or worse, a silent
* match. Returns null when the wrapper is present but its contents are not
* valid Base64.
* Strict: PHP's decoder accepts mispadded input and returns plausible
* bytes, which would turn a corrupted header into a silent mismatch.
* Null when the wrapper is present but its contents are not valid Base64.
*/
public static function decode(string $value): ?string
{
@ -247,8 +230,7 @@ final class StandardHeaderValidator
}
/**
* Header names compare case-insensitively, and values carry no meaning in
* the optional whitespace around them (RFC 9110 §5.5).
* Case-insensitive name, whitespace-trimmed value (RFC 9110 §5.5).
*
* @param array<string, string> $headers
*/

View file

@ -14,6 +14,7 @@ namespace Mcp\Server\Stateless;
use Mcp\Exception\InvalidInputMessageException;
use Mcp\Exception\MissingRequestMetaException;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Exception\RequestStateException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
@ -33,17 +34,9 @@ use Psr\Log\NullLogger;
/**
* Dispatches a single modern-era (SEP-2575) request.
*
* The handshake-era {@see \Mcp\Server\Protocol} is built around a session that
* outlives the request: it resolves one, replays `initialize` state into it,
* and keeps a fiber attached to it across HTTP round-trips. None of that
* survives into the modern era, where each request is self-describing and
* independent, so this is a separate dispatcher rather than a mode flag on the
* old one — the two eras share handlers, not control flow.
*
* Request handlers are reused verbatim. They expect a session, so each request
* gets an ephemeral one that is discarded when the request ends; handlers that
* merely stash per-request scratch state keep working, and handlers that tried
* to persist across requests would have had nothing to persist into anyway.
* Separate from {@see \Mcp\Server\Protocol} because the modern era has no
* session to resolve, replay or keep a fiber against; the two eras share
* request handlers, not control flow.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
@ -52,10 +45,8 @@ final class StatelessProtocol
private readonly WireCodecInterface $codec;
/**
* Methods the modern era deleted outright. They are answered as unknown
* methods rather than as errors specific to each, because that is exactly
* what they are to a modern server — listing them separately only serves
* to document the intent.
* Methods the modern era deleted. Answered as unknown methods, which is
* what they are to a modern server.
*/
public const REMOVED_METHODS = [
'initialize',
@ -91,6 +82,7 @@ final class StatelessProtocol
private readonly float $subscriptionLifetime = 30.0,
?WireCodecInterface $codec = null,
private readonly ?StandardHeaderValidator $headerValidator = null,
private readonly ?RequestStateCodec $requestStateCodec = null,
) {
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
}
@ -141,10 +133,8 @@ final class StatelessProtocol
return $versionError;
}
// After the version is settled, because a peer on the wrong revision
// has a more fundamental problem than a header that disagrees with its
// body — and telling it about the headers first would send it fixing
// the wrong thing.
// After the version check: a peer on the wrong revision has a more
// fundamental problem than headers that disagree with its body.
if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400);
}
@ -165,9 +155,6 @@ final class StatelessProtocol
return $this->listen($params, $id);
}
// Both the deleted methods and never-known ones are "not found". The
// 404 pairs the HTTP layer with the JSON-RPC code so a client that
// routes on status alone reaches the same conclusion.
if (\in_array($method, self::REMOVED_METHODS, true)) {
return StatelessResult::error(
Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id),
@ -179,14 +166,9 @@ final class StatelessProtocol
}
/**
* The header and `_meta` must agree on the version before that version can
* be judged supported or not.
*
* The order matters: when the two disagree, the server has been told two
* different things and cannot know which the client meant, so "these
* contradict" is the honest answer even if one of the two values happens to
* be unsupported. Reporting it as an unsupported version instead would send
* the client off to renegotiate a version that was never the problem.
* Header and `_meta` must agree before the version can be judged supported:
* when they disagree the server cannot know which the client meant, so a
* mismatch outranks an unsupported version.
*
* @param array<string, string> $headers
*/
@ -217,12 +199,8 @@ final class StatelessProtocol
}
/**
* Opens a `subscriptions/listen` stream.
*
* The subscription needs no identifier of its own: the spec defines it as
* the JSON-RPC id of this very request, which both sides already have. So
* there is no id to mint, store, or hand back — every frame is tagged with
* something the client chose.
* Opens a `subscriptions/listen` stream. The subscription id is the
* JSON-RPC id of this request, so there is none to mint.
*
* @param array<string, mixed>|null $params
*/
@ -243,20 +221,10 @@ final class StatelessProtocol
],
];
// Nothing else is emitted yet: delivering list-changed and
// resource-updated notifications means observing mutations made by
// *other* requests, which under a share-nothing runtime needs a
// cross-process channel this SDK does not yet define. Holding the
// stream open for a bounded window keeps the subscription honest —
// the client sees an open, acknowledged stream — without pinning a
// worker indefinitely for events that cannot arrive.
//
// The tick is what makes that bound real. PHP only learns the peer
// hung up by trying to write to it, so a loop that merely sleeps
// holds its worker for the full lifetime after the client has gone.
// On a fixed-size FPM pool a handful of abandoned subscriptions is
// enough to starve every other request, which is exactly what makes
// it worth the extra yield.
// Cross-request notifications need a channel this SDK does not
// define yet, so the stream only waits. The tick is not optional:
// PHP spots a dropped peer by writing, and a sleeping loop would
// pin an FPM worker for the full lifetime.
$deadline = microtime(true) + $lifetime;
while (microtime(true) < $deadline) {
yield null;
@ -268,8 +236,8 @@ final class StatelessProtocol
usleep(250_000);
}
// Graceful closure (SHOULD): tell the client the subscription ended
// deliberately, so it can distinguish this from a dropped transport.
// Graceful closure (SHOULD), so the client can tell this from a
// dropped transport.
yield [
'jsonrpc' => '2.0',
'id' => $id,
@ -342,6 +310,24 @@ final class StatelessProtocol
$session = new Session(new InMemorySessionStore());
$session->set(RequestMeta::class, $meta);
try {
$input = $this->liftInputContext($decoded['params'] ?? null);
} 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.
$this->logger->warning('Rejected a requestState that failed verification.', ['method' => $method, 'reason' => $e->getMessage()]);
return StatelessResult::error(Error::forInvalidParams('The supplied requestState failed verification.', $id), 400);
}
if (null !== $input) {
$session->set(InputContext::class, $input);
}
if (null !== $this->requestStateCodec) {
$session->set(RequestStateCodec::class, $this->requestStateCodec);
}
foreach ($this->requestHandlers as $handler) {
if (!$handler->supports($request)) {
continue;
@ -373,19 +359,51 @@ final class StatelessProtocol
}
/**
* Runs a result through the wire codec on its way out.
* Reads the multi round-trip material off a retry, verifying the state
* before any of it reaches a handler. Neither member means a first call,
* which is what a handler tests to decide whether it still needs to ask.
*
* The round-trip through JSON is what flattens the result — and everything
* nested inside it — into the plain arrays the codec stamps. Handing the
* codec objects instead would make it responsible for knowing how each one
* serializes, which is exactly the coupling this layer exists to avoid.
* @param array<string, mixed>|null $params
*
* @throws RequestStateException when a state is present but does not verify
*/
private function liftInputContext(?array $params): ?InputContext
{
$responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null;
$state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null;
if (null === $responses && null === $state) {
return null;
}
// Answers are result objects; dropping non-objects leaves the handler
// to ask again rather than read a malformed retry as satisfied.
if (null !== $responses) {
$responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response));
}
$payload = [];
if (null !== $state) {
// A server with no codec never minted a state, so this one cannot
// have come from here.
if (null === $this->requestStateCodec) {
throw new RequestStateException('mac');
}
$payload = $this->requestStateCodec->verify($state);
}
return new InputContext($responses ?? [], $payload);
}
/**
* Runs a result through the wire codec. Passed as-is rather than via a
* json round trip, which would turn a nested `{}` into `[]`.
*/
private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult
{
/** @var array<string, mixed> $body */
$body = json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR);
return StatelessResult::ok($id, $this->codec->encodeResult($method, $body));
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize()));
}
/**

View file

@ -17,10 +17,8 @@ use Mcp\Schema\JsonRpc\MessageInterface;
/**
* One modern-era answer, paired with the HTTP status that carries it.
*
* The pairing is the point. SEP-2575 fixes specific HTTP statuses to specific
* JSON-RPC error codes — 404 for a method that does not exist, 400 for a
* request the server could parse but not accept — so the status is part of the
* answer rather than something the transport re-derives from the error code.
* SEP-2575 fixes specific statuses to specific JSON-RPC error codes, so the
* status is part of the answer rather than something the transport re-derives.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
@ -40,11 +38,8 @@ final class StatelessResult
}
/**
* A successful answer whose result body the wire codec has already stamped.
*
* The body arrives as a plain array rather than a result object because the
* codec's job is to add fields the result classes deliberately do not model
* — once it has run, there is no object left that describes the payload.
* A successful answer whose body the wire codec has already stamped — a
* plain array, since no result class models the fields it added.
*
* @param array<string, mixed> $body
*/
@ -69,12 +64,9 @@ final class StatelessResult
}
/**
* A long-lived answer delivered as a sequence of frames rather than one
* message — `subscriptions/listen` is the only such method today.
*
* The generator is deferred rather than eager because the frames are
* produced over the life of the connection: building them up front would
* defeat the point of streaming and hold the whole subscription in memory.
* A long-lived answer delivered as frames — today only
* `subscriptions/listen`. Deferred, since the frames are produced over the
* life of the connection.
*
* @param \Closure(): \Generator<mixed> $frames
*/