[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:
parent
7ffc739fe7
commit
cad686aba7
11 changed files with 508 additions and 19 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing.
|
||||
* Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it.
|
||||
* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`.
|
||||
* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
96
src/Server/Stateless/InputRequestCapabilities.php
Normal file
96
src/Server/Stateless/InputRequestCapabilities.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
|||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Request\ListRootsRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ final class MrtrElements
|
|||
], requestState: $context->mintRequestState(['stage' => 'awaiting-input']));
|
||||
}
|
||||
|
||||
return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY))))]);
|
||||
return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY))))]);
|
||||
}
|
||||
|
||||
public static function sampling(RequestContext $context): CallToolResult|InputRequiredResult
|
||||
|
|
@ -198,14 +199,13 @@ final class MrtrElements
|
|||
], requestState: $context->mintRequestState(['stage' => 'awaiting-input']));
|
||||
}
|
||||
|
||||
return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->response(self::ELICIT_KEY)))]];
|
||||
return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY)))]];
|
||||
}
|
||||
|
||||
/** A declined or cancelled answer has no content, so the greeting falls back. */
|
||||
private static function nameFrom(mixed $response): string
|
||||
private static function nameFrom(?ElicitResult $result): string
|
||||
{
|
||||
$content = \is_array($response) ? ($response['content'] ?? null) : null;
|
||||
$name = \is_array($content) ? ($content['name'] ?? null) : null;
|
||||
$name = $result?->content['name'] ?? null;
|
||||
|
||||
return \is_string($name) ? $name : 'friend';
|
||||
}
|
||||
|
|
|
|||
111
tests/Unit/Server/Stateless/InputContextTest.php
Normal file
111
tests/Unit/Server/Stateless/InputContextTest.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?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\Tests\Unit\Server\Stateless;
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ElicitationMode;
|
||||
use Mcp\Server\Stateless\InputContext;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class InputContextTest extends TestCase
|
||||
{
|
||||
#[TestDox('an elicitation answer comes back typed')]
|
||||
public function testElicitResultIsParsed(): void
|
||||
{
|
||||
$context = new InputContext(['who' => ['action' => 'accept', 'content' => ['name' => 'ada']]]);
|
||||
|
||||
$result = $context->elicitResult('who');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame(ElicitAction::Accept, $result->action);
|
||||
$this->assertSame(['name' => 'ada'], $result->content);
|
||||
}
|
||||
|
||||
#[TestDox('an accepted url-mode answer needs no content')]
|
||||
public function testUrlModeElicitResultNeedsNoContent(): void
|
||||
{
|
||||
$context = new InputContext(['consent' => ['action' => 'accept']]);
|
||||
|
||||
$this->assertNull($context->elicitResult('consent'));
|
||||
$this->assertNotNull($context->elicitResult('consent', ElicitationMode::Url));
|
||||
}
|
||||
|
||||
#[TestDox('a sampling answer comes back typed')]
|
||||
public function testSamplingResultIsParsed(): void
|
||||
{
|
||||
$context = new InputContext(['capital' => [
|
||||
'role' => 'assistant',
|
||||
'content' => ['type' => 'text', 'text' => 'Paris'],
|
||||
'model' => 'a-model',
|
||||
'stopReason' => 'endTurn',
|
||||
]]);
|
||||
|
||||
$result = $context->samplingResult('capital');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('a-model', $result->model);
|
||||
$blocks = $result->getContentBlocks();
|
||||
$this->assertInstanceOf(TextContent::class, $blocks[0]);
|
||||
$this->assertSame('Paris', $blocks[0]->text);
|
||||
}
|
||||
|
||||
#[TestDox('a roots answer comes back typed')]
|
||||
public function testRootsResultIsParsed(): void
|
||||
{
|
||||
$context = new InputContext(['roots' => ['roots' => [['uri' => 'file:///work', 'name' => 'work']]]]);
|
||||
|
||||
$result = $context->rootsResult('roots');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertCount(1, $result->roots);
|
||||
$this->assertSame('file:///work', $result->roots[0]->uri);
|
||||
}
|
||||
|
||||
#[TestDox('an answer that was never given reads as absent')]
|
||||
public function testMissingKeyIsNull(): void
|
||||
{
|
||||
$context = new InputContext();
|
||||
|
||||
$this->assertNull($context->elicitResult('who'));
|
||||
$this->assertNull($context->samplingResult('who'));
|
||||
$this->assertNull($context->rootsResult('who'));
|
||||
$this->assertFalse($context->has('who'));
|
||||
}
|
||||
|
||||
#[TestDox('a malformed answer reads as absent, so the handler asks again rather than fails')]
|
||||
public function testMalformedAnswerIsNull(): void
|
||||
{
|
||||
$context = new InputContext([
|
||||
'who' => ['action' => 'not-an-action'],
|
||||
'capital' => ['role' => 'user', 'content' => []],
|
||||
'roots' => ['roots' => 'not a list'],
|
||||
]);
|
||||
|
||||
$this->assertNull($context->elicitResult('who'));
|
||||
$this->assertNull($context->samplingResult('capital'));
|
||||
$this->assertNull($context->rootsResult('roots'));
|
||||
|
||||
// Still present — the client did answer, just not with something usable.
|
||||
$this->assertTrue($context->has('who'));
|
||||
}
|
||||
|
||||
#[TestDox('the verified request state is carried alongside the answers')]
|
||||
public function testRequestStateIsCarried(): void
|
||||
{
|
||||
$context = new InputContext(['a' => ['action' => 'cancel']], ['round' => 2]);
|
||||
|
||||
$this->assertSame(['round' => 2], $context->requestState());
|
||||
$this->assertSame(['a'], array_keys($context->all()));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
|||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
|
|
@ -93,7 +95,41 @@ class StatelessProtocolTest extends TestCase
|
|||
name: 'elicits_directly',
|
||||
description: 'Asks the client directly, which this revision forbids',
|
||||
)
|
||||
->addTool(
|
||||
static fn (): InputRequiredResult => new InputRequiredResult([
|
||||
'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'),
|
||||
]),
|
||||
name: 'asks_by_url',
|
||||
description: 'Asks through a url-mode elicitation',
|
||||
)
|
||||
->addTool(
|
||||
static function (RequestContext $context): InputRequiredResult {
|
||||
$context->getClientGateway()->progress(0, 100, 'starting');
|
||||
|
||||
return new InputRequiredResult([
|
||||
'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'),
|
||||
]);
|
||||
},
|
||||
name: 'asks_by_url_after_progress',
|
||||
description: 'Emits progress, then asks through a url-mode elicitation',
|
||||
)
|
||||
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
|
||||
->addResource(
|
||||
static function (RequestContext $context): string|InputRequiredResult {
|
||||
$input = $context->getInputContext();
|
||||
|
||||
if (null === $input || !$input->has('who')) {
|
||||
return new InputRequiredResult([
|
||||
'who' => new ElicitRequest('Who is asking?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])),
|
||||
]);
|
||||
}
|
||||
|
||||
return 'body for '.($input->response('who')['content']['n'] ?? '?');
|
||||
},
|
||||
'test://gated',
|
||||
'gated',
|
||||
'A resource that asks who is reading before it answers',
|
||||
)
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +458,126 @@ class StatelessProtocolTest extends TestCase
|
|||
}
|
||||
}
|
||||
|
||||
#[TestDox('resources/read can ask for input before it answers')]
|
||||
public function testResourceReadCanAskForInput(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'resources/read',
|
||||
['uri' => 'test://gated'],
|
||||
['Mcp-Name' => 'test://gated'],
|
||||
['elicitation' => new \stdClass()],
|
||||
);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame('input_required', $answer['body']['result']['resultType']);
|
||||
$this->assertArrayHasKey('who', $answer['body']['result']['inputRequests']);
|
||||
|
||||
// Interim results are not cacheable and carry no hints.
|
||||
$this->assertArrayNotHasKey('ttlMs', $answer['body']['result']);
|
||||
$this->assertArrayNotHasKey('cacheScope', $answer['body']['result']);
|
||||
}
|
||||
|
||||
#[TestDox('an ask the client cannot answer is refused with -32021, not sent')]
|
||||
public function testUndeclaredInputRequestIsRefused(): void
|
||||
{
|
||||
// The client declared nothing, so it has no way to answer an
|
||||
// elicitation — and a retry carrying one could never arrive.
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'resources/read',
|
||||
['uri' => 'test://gated'],
|
||||
['Mcp-Name' => 'test://gated'],
|
||||
);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']);
|
||||
$this->assertArrayHasKey('elicitation', $answer['body']['error']['data']['requiredCapabilities']);
|
||||
}
|
||||
|
||||
#[TestDox('url-mode elicitation needs its own declaration, which form does not satisfy')]
|
||||
public function testUrlElicitationNeedsItsOwnDeclaration(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'asks_by_url', 'arguments' => []],
|
||||
['Mcp-Name' => 'asks_by_url'],
|
||||
['elicitation' => new \stdClass()],
|
||||
);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']);
|
||||
$this->assertArrayHasKey('url', $answer['body']['error']['data']['requiredCapabilities']['elicitation']);
|
||||
}
|
||||
|
||||
#[TestDox('a client declaring url mode gets the ask')]
|
||||
public function testUrlElicitationPassesWhenDeclared(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'asks_by_url', 'arguments' => []],
|
||||
['Mcp-Name' => 'asks_by_url'],
|
||||
['elicitation' => ['url' => new \stdClass()]],
|
||||
);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame('input_required', $answer['body']['result']['resultType']);
|
||||
}
|
||||
|
||||
#[TestDox('an ask the client cannot answer is refused with -32021 even mid-stream')]
|
||||
public function testUndeclaredInputRequestIsRefusedWhenStreamed(): void
|
||||
{
|
||||
// The handler emits progress before its InputRequiredResult, so the
|
||||
// answer goes out on the response stream, not a plain 400 — the gate
|
||||
// still has to apply there.
|
||||
$result = self::callStreaming(self::protocol(), 'asks_by_url_after_progress', ['progressToken' => 'tok-1']);
|
||||
|
||||
$this->assertTrue($result->isStream());
|
||||
|
||||
$frames = self::frames($result);
|
||||
$last = json_decode(json_encode($frames[array_key_last($frames)], \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $last['error']['code']);
|
||||
$this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']);
|
||||
}
|
||||
|
||||
#[TestDox('a first-round read carries caching hints')]
|
||||
public function testFirstRoundReadIsCacheable(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'resources/read',
|
||||
['uri' => 'test://static'],
|
||||
['Mcp-Name' => 'test://static'],
|
||||
);
|
||||
|
||||
$this->assertArrayHasKey('ttlMs', $answer['body']['result']);
|
||||
$this->assertArrayHasKey('cacheScope', $answer['body']['result']);
|
||||
}
|
||||
|
||||
#[TestDox('a result produced by a multi round-trip retry carries no caching hints')]
|
||||
public function testMrtrRetryResultIsNotCacheable(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'resources/read',
|
||||
[
|
||||
'uri' => 'test://gated',
|
||||
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['n' => 'ada']]],
|
||||
],
|
||||
['Mcp-Name' => 'test://gated'],
|
||||
['elicitation' => new \stdClass()],
|
||||
);
|
||||
|
||||
$this->assertSame('complete', $answer['body']['result']['resultType']);
|
||||
// The inputs are not part of any cache key, so the answer must not be
|
||||
// presented as reusable.
|
||||
$this->assertArrayNotHasKey('ttlMs', $answer['body']['result']);
|
||||
$this->assertArrayNotHasKey('cacheScope', $answer['body']['result']);
|
||||
}
|
||||
|
||||
#[TestDox('a notification is acknowledged with no body, never answered')]
|
||||
public function testNotificationIsAcknowledged(): void
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue