php-sdk/src/Server/Stateless/RequestStateCodec.php
Christopher Hertel ef0c1c2808
[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.
2026-08-18 22:45:35 +02:00

125 lines
3.9 KiB
PHP

<?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;
}
}