[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:
parent
de35ca40bf
commit
ef0c1c2808
31 changed files with 906 additions and 341 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`.
|
||||
* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored.
|
||||
* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them.
|
||||
* Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response.
|
||||
|
|
|
|||
9
Makefile
9
Makefile
|
|
@ -1,8 +1,8 @@
|
|||
.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client check-conformance-repo docs
|
||||
|
||||
# The published @modelcontextprotocol/conformance release carries no 2026-07-28
|
||||
# scenarios, so the draft targets below run a local checkout of the conformance
|
||||
# repo instead. Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`.
|
||||
# The published conformance release carries no 2026-07-28 scenarios, so the
|
||||
# draft targets run a local checkout instead.
|
||||
# Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`.
|
||||
CONFORMANCE_REPO ?= $(CURDIR)/../conformance
|
||||
CONFORMANCE_DRAFT = node $(CONFORMANCE_REPO)/dist/index.js
|
||||
|
||||
|
|
@ -47,8 +47,7 @@ conformance-client:
|
|||
php tests/Conformance/score.php client
|
||||
|
||||
# --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------
|
||||
# Local-checkout only for now; not wired into CI until upstream publishes a
|
||||
# release carrying the draft scenarios.
|
||||
# Local-checkout only until upstream publishes the draft scenarios.
|
||||
|
||||
conformance-draft: conformance-draft-server conformance-draft-client
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@
|
|||
namespace Mcp\Exception;
|
||||
|
||||
/**
|
||||
* A modern-era request omitted metadata the server structurally needs to
|
||||
* answer it — the protocol version or the client's capabilities.
|
||||
*
|
||||
* Answered with a JSON-RPC `-32602 Invalid params` error and HTTP 400.
|
||||
* A modern-era request omitted the protocol version or client capabilities.
|
||||
* Answered with `-32602 Invalid params` and HTTP 400.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,16 +14,11 @@ namespace Mcp\Exception;
|
|||
use Mcp\Schema\ClientCapabilities;
|
||||
|
||||
/**
|
||||
* Answering the request needs a client capability the request never declared.
|
||||
*
|
||||
* In the handshake era a server learned the client's capabilities once, at
|
||||
* `initialize`, and could refuse to advertise features the client could not
|
||||
* use. A modern request restates its capabilities every time, so the check
|
||||
* moves to call time — a handler that needs to call back into the client
|
||||
* throws this, and the protocol answers `-32021` with HTTP 400.
|
||||
* Answering the request needs a client capability it never declared; the
|
||||
* protocol answers `-32021` with HTTP 400.
|
||||
*
|
||||
* The capabilities travel as a {@see ClientCapabilities} object rather than a
|
||||
* list of names so the client can compare them against what it would send.
|
||||
* list of names, so the client can compare them against what it would send.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
|
|
|
|||
24
src/Exception/RequestStateException.php
Normal file
24
src/Exception/RequestStateException.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?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\Exception;
|
||||
|
||||
/**
|
||||
* An echoed `requestState` failed verification.
|
||||
*
|
||||
* The message is a bare reason code — `malformed`, `mac` or `expired` —
|
||||
* because whoever sent the value may be probing.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class RequestStateException extends InvalidArgumentException
|
||||
{
|
||||
}
|
||||
|
|
@ -14,13 +14,9 @@ namespace Mcp\Schema\Enum;
|
|||
/**
|
||||
* How widely a cacheable result may be reused (SEP-2549).
|
||||
*
|
||||
* The analogue of HTTP's `Cache-Control: public` vs `private`. The distinction
|
||||
* is a security boundary, not a performance knob: marking a result public
|
||||
* allows shared gateways and proxies to serve it to *other* authorization
|
||||
* contexts, so anything shaped by who asked must stay private.
|
||||
*
|
||||
* {@see self::Private} is therefore the default everywhere in this SDK — an
|
||||
* over-cautious hint costs a cache hit, an over-broad one leaks a response.
|
||||
* A security boundary, not a performance knob: `public` lets shared caches
|
||||
* serve the result to other authorization contexts, so anything shaped by who
|
||||
* asked must stay private — which is why private is the default throughout.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,13 +14,9 @@ namespace Mcp\Schema\Enum;
|
|||
/**
|
||||
* Tells the client how to read a result before it looks at the body.
|
||||
*
|
||||
* Introduced in 2026-07-28, where a request can come back either finished or
|
||||
* asking for more input; without a discriminator the client would have to
|
||||
* infer which by probing for fields. Servers on this revision MUST send it.
|
||||
*
|
||||
* Earlier revisions have no such field, and a client receiving a result
|
||||
* without one MUST read it as {@see self::Complete} — which is what makes it
|
||||
* safe to emit unconditionally rather than only on the newer revision.
|
||||
* Required from 2026-07-28, where a request may come back finished or asking
|
||||
* for input. A client seeing no `resultType` MUST read it as
|
||||
* {@see self::Complete}.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -20,13 +20,9 @@ use Mcp\Schema\ServerCapabilities;
|
|||
/**
|
||||
* The server's response to a `server/discover` request.
|
||||
*
|
||||
* This is the modern era's replacement for `InitializeResult`. The difference
|
||||
* is not just the method name: `initialize` *negotiates* a single version and
|
||||
* remembers it for the connection, whereas `server/discover` merely *reports*
|
||||
* every version this server speaks and lets each subsequent request pick one.
|
||||
*
|
||||
* Server identity travels in the result `_meta` rather than in a body
|
||||
* `serverInfo` member (spec PR #3002 removed the latter).
|
||||
* The modern era's replacement for `InitializeResult`: it reports every
|
||||
* version this server speaks rather than negotiating one, and each request
|
||||
* picks from that list.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
|
|
@ -46,9 +42,8 @@ class DiscoverResult implements ResultInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* Carries no `resultType`, caching hints or serverInfo identity: those are
|
||||
* wire vocabulary, stamped by {@see \Mcp\Server\Wire\Rev2026Codec} on the
|
||||
* way out. What is modelled here is only what discovery actually answers.
|
||||
* `resultType`, caching hints and serverInfo are wire vocabulary, stamped
|
||||
* by {@see \Mcp\Server\Wire\Rev2026Codec} rather than modelled here.
|
||||
*
|
||||
* @return array{
|
||||
* supportedVersions: list<string>,
|
||||
|
|
|
|||
|
|
@ -20,18 +20,10 @@ use Mcp\Schema\Request\ListRootsRequest;
|
|||
/**
|
||||
* Tells the client the server needs more input before it can finish (MRTR).
|
||||
*
|
||||
* This is how a modern-era server asks the client for anything — elicitation,
|
||||
* sampling, roots. The handshake era let a server open its own request to the
|
||||
* client mid-call; 2026-07-28 removed that outright, so the ask travels back as
|
||||
* part of the *result* and the client re-sends the original call with the
|
||||
* answers attached. The exchange is two independent requests, which is what
|
||||
* lets any server instance serve the retry.
|
||||
*
|
||||
* `requestState` is the server's own context, opaque to the client and echoed
|
||||
* back verbatim. Because it round-trips through the client it is
|
||||
* attacker-controlled on return: anything that influences authorization or
|
||||
* business logic MUST be integrity-protected and verified, and rejected when
|
||||
* verification fails.
|
||||
* The modern era removed server-initiated requests, so the ask travels back in
|
||||
* the result and the client re-sends the original call with the answers. The
|
||||
* `requestState` round-trips through the client and is therefore
|
||||
* attacker-controlled on return — see {@see \Mcp\Server\Stateless\RequestStateCodec}.
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
|
||||
*
|
||||
|
|
@ -49,9 +41,7 @@ class InputRequiredResult implements ResultInterface
|
|||
public readonly array $inputRequests = [],
|
||||
public readonly ?string $requestState = null,
|
||||
) {
|
||||
// "Servers MUST include at least one of inputRequests or requestState
|
||||
// in every InputRequiredResult response." A result carrying neither
|
||||
// tells the client to retry with nothing new, which loops forever.
|
||||
// Neither member would tell the client to retry with nothing new.
|
||||
if ([] === $this->inputRequests && null === $this->requestState) {
|
||||
throw new InvalidArgumentException('An InputRequiredResult must carry at least one of "inputRequests" or "requestState".');
|
||||
}
|
||||
|
|
@ -71,21 +61,17 @@ class InputRequiredResult implements ResultInterface
|
|||
if ([] !== $this->inputRequests) {
|
||||
$requests = [];
|
||||
foreach ($this->inputRequests as $key => $request) {
|
||||
// The map values are bare method/params pairs rather than whole
|
||||
// JSON-RPC requests: they are not messages in their own right,
|
||||
// and giving them ids would imply a correlation the retry does
|
||||
// not use — the client keys its answers by the map key instead.
|
||||
//
|
||||
// Params are reachable only through the JSON-RPC envelope
|
||||
// (getParams() is protected), and rendering that envelope needs
|
||||
// an id these requests never have. Hence the throwaway id: it
|
||||
// exists for the length of this expression and is discarded
|
||||
// with the envelope it was needed to build.
|
||||
// Values are bare method/params pairs, not messages: the client
|
||||
// keys answers by the map key. getParams() is protected, so the
|
||||
// envelope is built with a throwaway id and then discarded.
|
||||
$envelope = $request->withId(0)->jsonSerialize();
|
||||
|
||||
$params = $envelope['params'] ?? null;
|
||||
|
||||
$requests[$key] = [
|
||||
'method' => $request::getMethod(),
|
||||
'params' => $envelope['params'] ?? new \stdClass(),
|
||||
// An empty PHP array would encode as `[]`, not `{}`.
|
||||
'params' => [] === $params || null === $params ? new \stdClass() : $params,
|
||||
];
|
||||
}
|
||||
$data['inputRequests'] = $requests;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore;
|
|||
use Mcp\Server\Session\SessionManager;
|
||||
use Mcp\Server\Session\SessionManagerInterface;
|
||||
use Mcp\Server\Session\SessionStoreInterface;
|
||||
use Mcp\Server\Stateless\RequestStateCodec;
|
||||
use Mcp\Server\Stateless\StandardHeaderValidator;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
|
@ -106,6 +107,10 @@ final class Builder
|
|||
|
||||
private ?ProtocolVersion $protocolVersion = null;
|
||||
|
||||
private ?string $requestStateKey = null;
|
||||
|
||||
private int $requestStateTtl = 600;
|
||||
|
||||
/**
|
||||
* @var array<int, RequestHandlerInterface<mixed>>
|
||||
*/
|
||||
|
|
@ -250,6 +255,25 @@ final class Builder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the key signing the `requestState` carried across the rounds of a
|
||||
* multi round-trip request (SEP-2322). Without one, every echoed state is
|
||||
* refused.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
public function setRequestState(string $key, int $ttl = 600): self
|
||||
{
|
||||
$this->requestStateKey = $key;
|
||||
$this->requestStateTtl = $ttl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the server's pagination limit.
|
||||
*/
|
||||
|
|
@ -717,11 +741,9 @@ final class Builder
|
|||
/**
|
||||
* Builds a dispatcher for the modern (SEP-2575) lifecycle.
|
||||
*
|
||||
* Everything the server exposes — tools, prompts, resources, and the
|
||||
* handlers that serve them — is era-independent and shared with
|
||||
* {@see self::build()}; only the dispatch model differs. So one builder
|
||||
* configuration can drive either lifecycle, and a server can offer both at
|
||||
* once by mounting each on its own endpoint.
|
||||
* Tools, prompts, resources and their handlers are era-independent, so one
|
||||
* builder configuration drives either lifecycle and a server can offer both
|
||||
* by mounting each on its own endpoint.
|
||||
*
|
||||
* @param list<ProtocolVersion> $supportedVersions revisions this dispatcher will answer for
|
||||
*/
|
||||
|
|
@ -736,6 +758,9 @@ final class Builder
|
|||
supportedVersions: $supportedVersions,
|
||||
logger: $parts['logger'],
|
||||
headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null,
|
||||
requestStateCodec: null !== $this->requestStateKey
|
||||
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ use Psr\Log\LoggerInterface;
|
|||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* A tools/call can answer with the tool's output or, under MRTR, with a request
|
||||
* for the input the tool still needs — hence the two result types.
|
||||
* A tools/call answers with the tool's output or, under MRTR, with a request
|
||||
* for the input it still needs.
|
||||
*
|
||||
* @implements RequestHandlerInterface<CallToolResult|InputRequiredResult>
|
||||
*
|
||||
|
|
@ -103,7 +103,7 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
try {
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
// Tool asking for more input as response
|
||||
// An ask is a result in its own right, not tool output.
|
||||
if ($result instanceof InputRequiredResult) {
|
||||
return new Response($request->getId(), $result);
|
||||
}
|
||||
|
|
@ -146,6 +146,8 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
|
||||
return new Response($request->getId(), $result);
|
||||
} catch (MissingRequiredClientCapabilityException $e) {
|
||||
// Not a tool failure — the request was unservable, and the client
|
||||
// needs to retry declaring the capability. Rendered as -32021.
|
||||
throw $e;
|
||||
} catch (ToolCallException $e) {
|
||||
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [
|
||||
|
|
|
|||
|
|
@ -20,12 +20,13 @@ use Mcp\Schema\JsonRpc\Request;
|
|||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\GetPromptRequest;
|
||||
use Mcp\Schema\Result\GetPromptResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<GetPromptResult>
|
||||
* @implements RequestHandlerInterface<GetPromptResult|InputRequiredResult>
|
||||
*
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
|
|
@ -44,7 +45,7 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return Response<GetPromptResult>|Error
|
||||
* @return Response<GetPromptResult|InputRequiredResult>|Error
|
||||
*/
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
|
|
@ -61,6 +62,11 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
// An ask is a result in its own right, not prompt content.
|
||||
if ($result instanceof InputRequiredResult) {
|
||||
return new Response($request->getId(), $result);
|
||||
}
|
||||
|
||||
$formatted = $reference->formatResult($result);
|
||||
|
||||
return new Response($request->getId(), new GetPromptResult($formatted));
|
||||
|
|
|
|||
|
|
@ -12,9 +12,14 @@
|
|||
namespace Mcp\Server;
|
||||
|
||||
use Mcp\Capability\Logger\ClientLogger;
|
||||
use Mcp\Exception\LogicException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Mcp\Server\Stateless\InputContext;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Stateless\RequestStateCodec;
|
||||
|
||||
/**
|
||||
* Context related to a single request. This includes information about the session and
|
||||
|
|
@ -82,6 +87,48 @@ final class RequestContext
|
|||
return $this->clientGateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a multi round-trip retry carried back, or null on a first call —
|
||||
* which is the signal to return an
|
||||
* {@see \Mcp\Schema\Result\InputRequiredResult} instead of an answer.
|
||||
*/
|
||||
public function getInputContext(): ?InputContext
|
||||
{
|
||||
$context = $this->session->get(InputContext::class);
|
||||
|
||||
return $context instanceof InputContext ? $context : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the client declared on this request. A server MUST NOT ask for
|
||||
* input the client cannot supply. Null in the handshake era, where
|
||||
* capabilities are connection state.
|
||||
*/
|
||||
public function getClientCapabilities(): ?ClientCapabilities
|
||||
{
|
||||
$meta = $this->session->get(RequestMeta::class);
|
||||
|
||||
return $meta instanceof RequestMeta ? $meta->clientCapabilities : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seals handler context into the string an
|
||||
* {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client.
|
||||
* Signed, not encrypted: nothing secret belongs in the payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function mintRequestState(array $payload): string
|
||||
{
|
||||
$codec = $this->session->get(RequestStateCodec::class);
|
||||
|
||||
if (!$codec instanceof RequestStateCodec) {
|
||||
throw new LogicException('No requestState signing key is configured; call Builder::setRequestState() before returning state from a handler.');
|
||||
}
|
||||
|
||||
return $codec->mint($payload);
|
||||
}
|
||||
|
||||
public function getClientLogger(): ClientLogger
|
||||
{
|
||||
if (null === $this->clientLogger) {
|
||||
|
|
|
|||
63
src/Server/Stateless/InputContext.php
Normal file
63
src/Server/Stateless/InputContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
125
src/Server/Stateless/RequestStateCodec.php
Normal file
125
src/Server/Stateless/RequestStateCodec.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue