[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
3 changed files with 508 additions and 19 deletions

View file

@ -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';
}

View 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()));
}
}

View file

@ -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
{