[Server] Complete the multi round-trip request surface (SEP-2322) (#449)

* [Server] Let resources/read ask for input, and stop caching retries

The MRTR pattern names three methods and resources/read was the one whose
handler dropped an InputRequiredResult into the resource formatter. A
retry's answer also carried ttlMs and cacheScope, which the caching rules
forbid: its inputs are not part of any cache key.

* [Server] Refuse an ask the client cannot answer

A handler could put an elicitation into inputRequests for a client that
never declared one, and the only symptom was a retry that never came.
Checked against the request's own capabilities now, and reported as -32021
with the missing set — which is the code the spec defines for exactly this.

* [Server] Hand multi round-trip answers back typed

response() returned whatever JSON arrived, so every handler re-implemented
the same array poking. A malformed answer now reads as absent, which sends
the handler back to asking rather than to failing.

* [Server] Gate streamed input_required asks by capability too

checkInputRequests() only ran on the non-streaming path, so a handler
that emits a notification before returning InputRequiredResult could
still leak an undeclared-capability ask under HTTP 200.
This commit is contained in:
Christopher Hertel 2026-08-18 23:41:00 +02:00 • committed by GitHub
commit cad686aba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 508 additions and 19 deletions

View file

@ -21,6 +21,7 @@ use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Server\RequestContext;
use Mcp\Server\Session\SessionInterface;
@ -28,7 +29,7 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @implements RequestHandlerInterface<ReadResourceResult>
* @implements RequestHandlerInterface<ReadResourceResult|InputRequiredResult>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
@ -47,7 +48,7 @@ final class ReadResourceHandler implements RequestHandlerInterface
}
/**
* @return Response<ReadResourceResult>|Error
* @return Response<ReadResourceResult|InputRequiredResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
@ -71,9 +72,20 @@ final class ReadResourceHandler implements RequestHandlerInterface
$arguments = array_merge($arguments, $variables);
$result = $this->referenceHandler->handle($reference, $arguments);
// An ask is a result in its own right, not resource contents.
if ($result instanceof InputRequiredResult) {
return new Response($request->getId(), $result);
}
$formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType);
} else {
$result = $this->referenceHandler->handle($reference, $arguments);
if ($result instanceof InputRequiredResult) {
return new Response($request->getId(), $result);
}
$formatted = $reference->formatResult($result, $uri, $reference->resource->mimeType);
}

View file

@ -11,6 +11,12 @@
namespace Mcp\Server\Stateless;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\ListRootsResult;
/**
* What a retry brought back: the client's answers to a previous
* {@see \Mcp\Schema\Result\InputRequiredResult}, keyed as its `inputRequests`
@ -40,6 +46,62 @@ final class InputContext
return $this->responses[$key] ?? null;
}
/**
* The client's answer to an `elicitation/create` ask, typed.
*
* Null when the key is absent or the answer does not parse — which the
* caller should read as "not answered" and ask again, rather than as an
* error: the specification says a server SHOULD re-ask for information it
* still needs instead of failing the request.
*
* @param ElicitationMode $mode the mode of the ask this answers; url-mode
* answers carry no content, so an accepted one
* is complete without it
*/
public function elicitResult(string $key, ElicitationMode $mode = ElicitationMode::Form): ?ElicitResult
{
return $this->parse($key, static fn (array $data): ElicitResult => ElicitResult::fromArray($data, $mode));
}
/**
* The client's answer to a `sampling/createMessage` ask, typed.
*/
public function samplingResult(string $key): ?CreateSamplingMessageResult
{
return $this->parse($key, CreateSamplingMessageResult::fromArray(...));
}
/**
* The client's answer to a `roots/list` ask, typed.
*/
public function rootsResult(string $key): ?ListRootsResult
{
return $this->parse($key, ListRootsResult::fromArray(...));
}
/**
* @template T of ResultInterface
*
* @param callable(array<string, mixed>): T $factory
*
* @return T|null
*/
private function parse(string $key, callable $factory): ?ResultInterface
{
$response = $this->responses[$key] ?? null;
if (!\is_array($response)) {
return null;
}
try {
return $factory($response);
} catch (\Throwable) {
// A malformed answer is one the client did not really give.
return null;
}
}
public function has(string $key): bool
{
return \array_key_exists($key, $this->responses);

View file

@ -0,0 +1,96 @@
<?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\ClientCapabilities;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\Enum\SamplingContext;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\InputRequiredResult;
/**
* What a multi round-trip ask requires of the client, and whether it said it
* could do it.
*
* A server MUST NOT put a request into `inputRequests` that the client has not
* declared support for: the client would have nothing to answer with and the
* retry could never carry it. Since capabilities travel per request in this
* revision, the check is possible right where the answer is built — which is
* better than leaving it to every handler, and better than discovering it when
* the retry never comes.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class InputRequestCapabilities
{
/**
* The capabilities $result needs that $declared does not offer, or null
* when the client can answer everything it is being asked.
*/
public static function missing(InputRequiredResult $result, ClientCapabilities $declared): ?ClientCapabilities
{
$roots = false;
$sampling = false;
$samplingTools = false;
$samplingContext = false;
$elicitationForm = false;
$elicitationUrl = false;
foreach ($result->inputRequests as $request) {
if ($request instanceof ListRootsRequest) {
$roots = $roots || true !== $declared->roots;
continue;
}
if ($request instanceof CreateSamplingMessageRequest) {
$sampling = $sampling || true !== $declared->sampling;
if (null !== $request->tools || null !== $request->toolChoice) {
$samplingTools = $samplingTools || true !== $declared->samplingTools;
}
// `none` is the default and needs nothing; the other two are
// deprecated values that only a declaring client understands.
if (null !== $request->includeContext && SamplingContext::NONE !== $request->includeContext) {
$samplingContext = $samplingContext || true !== $declared->samplingContext;
}
continue;
}
if ($request instanceof ElicitRequest) {
if (ElicitationMode::Url === $request->mode) {
$elicitationUrl = $elicitationUrl || true !== $declared->elicitationUrl;
} else {
$elicitationForm = $elicitationForm || true !== $declared->elicitationForm;
}
}
}
if (!$roots && !$sampling && !$samplingTools && !$samplingContext && !$elicitationForm && !$elicitationUrl) {
return null;
}
return new ClientCapabilities(
roots: $roots,
sampling: $sampling ?: null,
elicitation: ($elicitationForm || $elicitationUrl) ?: null,
samplingContext: $samplingContext ?: null,
samplingTools: $samplingTools ?: null,
elicitationForm: $elicitationForm ?: null,
elicitationUrl: $elicitationUrl ?: null,
);
}
}

View file

@ -25,6 +25,7 @@ use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Result\DiscoverResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\Configuration;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Protocol;
@ -433,7 +434,7 @@ final class StatelessProtocol
}
if ($run->valid() && $wantsStream) {
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id));
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input));
}
try {
@ -457,7 +458,11 @@ final class StatelessProtocol
return StatelessResult::error($result, 400);
}
return $this->encode($method, $id, $result->result);
if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
return $capabilityError;
}
return $this->encode($method, $id, $result->result, null === $input);
}
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
@ -540,7 +545,7 @@ final class StatelessProtocol
*
* @return \Generator<mixed>
*/
private function streamFrames(\Generator $run, string $method, string|int $id): \Generator
private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
{
try {
while ($run->valid()) {
@ -558,9 +563,50 @@ final class StatelessProtocol
return;
}
if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
yield $capabilityError->message?->jsonSerialize();
return;
}
yield $result instanceof Error
? $result->jsonSerialize()
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())];
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)];
}
/**
* Refuses to send an ask the client cannot answer.
*
* The handler's mistake rather than the client's, but the client is the one
* that has to hear about it, and `-32021` is precisely the code for
* "processing this needs a capability you did not declare" — so it is
* reported as that, and logged as the server-side bug it is.
*/
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult
{
if (!$result instanceof InputRequiredResult) {
return null;
}
$missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities);
if (null === $missing) {
return null;
}
$this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [
'method' => $method,
'required' => $missing->jsonSerialize(),
]);
return StatelessResult::error(
Error::forMissingRequiredClientCapability(
'The server needs input this client did not declare it can provide.',
$missing,
$id,
),
400,
);
}
/**
@ -634,9 +680,9 @@ final class StatelessProtocol
* 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
private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult
{
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize()));
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable));
}
/**

View file

@ -21,7 +21,7 @@ namespace Mcp\Server\Wire;
*/
final class Rev2025Codec implements WireCodecInterface
{
public function encodeResult(string $method, array $result): array
public function encodeResult(string $method, array $result, bool $cacheable = true): array
{
return $result;
}

View file

@ -51,10 +51,12 @@ final class Rev2026Codec implements WireCodecInterface
) {
}
public function encodeResult(string $method, array $result): array
public function encodeResult(string $method, array $result, bool $cacheable = true): array
{
$stamped = $this->stampResultType($method, $result);
return $this->stampServerInfo(
$this->fillCacheHints($method, $this->stampResultType($method, $result)),
$cacheable ? $this->fillCacheHints($method, $stamped) : $stamped,
);
}

View file

@ -29,10 +29,13 @@ interface WireCodecInterface
/**
* Stamps an already-serialized result with whatever this era requires.
*
* @param string $method the request method the result answers
* @param array<string, mixed> $result the neutral result body
* @param string $method the request method the result answers
* @param array<string, mixed> $result the neutral result body
* @param bool $cacheable whether this answer may carry caching hints at all — false for one
* produced by a multi round-trip retry, whose inputs are not part of
* any cache key
*
* @return array<string, mixed>
*/
public function encodeResult(string $method, array $result): array;
public function encodeResult(string $method, array $result, bool $cacheable = true): array;
}