[Server] Serve ClientGateway::elicit() under the modern lifecycle (#466)

One handler asks the user something on any revision now. Where the client
can be asked mid-request it still is; where it cannot, the ask becomes the
input_required result that revision carries and the same call returns the
answer once the client re-sends it.

Costs one handler entry per ask, so side effects belong after the last
question. Answers from earlier rounds travel in the requestState, which is
why asking more than once needs Builder::setRequestState().

sample() and listRoots() still raise a LogicException there: that revision
removed them outright.
This commit is contained in:
Christopher Hertel 2026-08-20 01:25:59 +02:00 • committed by GitHub
commit e0b9c1a26f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -17,9 +17,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\RequestContext;
use Psr\Log\LoggerInterface;
@ -46,10 +44,10 @@ final class ElicitationHandlers
* - String field with date format for reservation date
* - Enum field for dietary restrictions with human-readable labels
*
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}
*/
#[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')]
public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult
public function bookRestaurant(RequestContext $context, string $restaurantName): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
@ -93,12 +91,6 @@ final class ElicitationHandlers
$schema,
);
// Modern era, first round: the ask travels back as the result and the
// client retries this whole call carrying the answer.
if ($result instanceof InputRequiredResult) {
return $result;
}
if ($result->isDeclined()) {
$this->logger->info('User declined to provide reservation details.');
@ -162,10 +154,10 @@ final class ElicitationHandlers
*
* Demonstrates the simplest elicitation pattern - a yes/no confirmation.
*
* @return array{status: string, message: string}|InputRequiredResult
* @return array{status: string, message: string}
*/
#[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')]
public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult
public function confirmAction(RequestContext $context, string $actionDescription): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
@ -192,10 +184,6 @@ final class ElicitationHandlers
$schema,
);
if ($result instanceof InputRequiredResult) {
return $result;
}
if (!$result->isAccepted()) {
return [
'status' => 'not_confirmed',
@ -234,10 +222,10 @@ final class ElicitationHandlers
*
* Demonstrates elicitation with optional fields and enum with labels.
*
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}
*/
#[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')]
public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult
public function collectFeedback(RequestContext $context, string $topic): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
@ -270,10 +258,6 @@ final class ElicitationHandlers
$schema,
);
if ($result instanceof InputRequiredResult) {
return $result;
}
if (!$result->isAccepted()) {
return [
'status' => 'skipped',
@ -308,22 +292,19 @@ final class ElicitationHandlers
/**
* Ask the user one question.
*
* Written the way revision 2026-07-28 asks: the question is *returned*, the
* client answers it and retries the whole call, and the answer comes back
* through the input context under the same key. Nothing here names an era —
* on a handshake-era connection the SDK fulfils the same ask over that
* connection's own channel and re-enters the tool with the answer.
*
* The caller gets an {@see ElicitResult} once there is one, or an
* {@see InputRequiredResult} to hand straight back to its own caller.
* One call, every revision. Where the client can be interrupted mid-tool it
* is, and this returns the answer to that; from 2026-07-28 on there is no
* interrupting, so the SDK ends the call with the ask and returns here when
* the client re-sends it with the answer — which means everything above this
* line runs once per question. The `$key` is what ties an answer to the
* question it belongs to across those rounds.
*/
private function ask(
RequestContext $context,
string $key,
string $message,
ElicitationSchema $schema,
): ElicitResult|InputRequiredResult {
return $context->getInputContext()?->elicitResult($key)
?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]);
): ElicitResult {
return $context->getClientGateway()->elicit($message, $schema, key: $key);
}
}