[Client] Add client-side elicitation support (#371)

* [Client] Add client-side elicitation support

The SDK could send elicitation/create requests from a server tool via
ClientGateway::elicit(), but the client could not receive them: there was
no request handler, and ElicitRequest was not registered in MessageFactory,
so incoming elicitation/create messages failed to parse.

This adds the client half, mirroring the existing Sampling pattern:

- Register ElicitRequest in MessageFactory so elicitation/create parses.
- ElicitationRequestHandler wraps a user callback and returns an
  ElicitResult (accept/decline/cancel) or an Error.
- ElicitationCallbackInterface defines the callback contract.
- ElicitationException forwards a specific message to the server; any
  other throwable returns a generic error.

Includes unit tests, a runnable client example against the elicitation
demo server, and client docs/README updates.

* [Client] Simplify elicitation example labelFor() per review

Type the parameter as AbstractSchemaDefinition and read ->title directly
instead of matching on each concrete definition type. title is a required
non-null string on the base class, so the match and the $name fallback were
redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Johannes Wachter 2026-07-14 23:55:06 +02:00 • committed by GitHub
commit e4a8a224ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 495 additions and 1 deletions

View file

@ -210,6 +210,7 @@ $client->disconnect();
- **Resource Access**: Read static and dynamic resources
- **Prompt Management**: List and retrieve prompt templates
- **Completion Support**: Request argument completion suggestions
- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests
### Advanced Features
@ -233,6 +234,15 @@ $client = Client::builder()
->build();
```
- **Elicitation Support**: Respond to server requests for user input
```php
$elicitationHandler = new ElicitationRequestHandler($myCallback);
$client = Client::builder()
->setCapabilities(new ClientCapabilities(elicitation: true))
->addRequestHandler($elicitationHandler)
->build();
```
- **Logging Notifications**: Receive server log messages
```php
$loggingHandler = new LoggingNotificationHandler($myCallback);

View file

@ -5,7 +5,7 @@ This roadmap is a living document that outlines the planned features and improve
## Goals for the First Major Release
- **Server**
- [ ] Implement full support for elicitations
- [x] Implement full support for elicitations
- [ ] Implement OAuth2 authentication for server
- **Client**
- [x] Implement client-side support

View file

@ -534,6 +534,72 @@ $client = Client::builder()
> throw new \RuntimeException('Rate limit exceeded');
> ```
### Elicitation (User Input Requests)
Handle server requests to elicit additional information from the user during tool
execution. The server sends an `elicitation/create` request describing the fields it
needs; your callback presents them to the user and returns an `ElicitResult` with one of
three actions — accept (with the collected content), decline, or cancel:
```php
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
use Mcp\Exception\ElicitationException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
class ConsoleElicitationCallback implements ElicitationCallbackInterface
{
public function __invoke(ElicitRequest $request): ElicitResult
{
echo $request->message.\PHP_EOL;
// Present $request->requestedSchema->properties to the user and collect input.
$content = [];
foreach ($request->requestedSchema->properties as $name => $definition) {
$answer = readline($definition->title.': ');
if (false === $answer) {
// No input available — let the server know the user cancelled.
return new ElicitResult(ElicitAction::Cancel);
}
$content[$name] = $answer;
}
return new ElicitResult(ElicitAction::Accept, $content);
}
}
$client = Client::builder()
->setCapabilities(new ClientCapabilities(elicitation: true))
->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback))
->build();
```
Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the
information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request.
Only the `Accept` action carries content.
> [!IMPORTANT]
> **Error Handling in Elicitation Callbacks:**
>
> - **Throw `ElicitationException`** to forward a specific error message to the server
> - **Any other exception** is logged but returns a generic error to the server
>
> ```php
> // Good: Server receives "No interactive console available" message
> throw new ElicitationException('No interactive console available');
>
> // Bad: Server receives generic "Error while processing elicitation" message
> throw new \RuntimeException('No interactive console available');
> ```
See `examples/client/stdio_elicitation.php` for a runnable example against the
elicitation demo server.
## Error Handling
The client throws exceptions for various error conditions:

View file

@ -0,0 +1,144 @@
<?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.
*/
/**
* STDIO Client Elicitation Example.
*
* This example demonstrates how a client responds to server-initiated
* elicitation/create requests. The server's tools ask the user for additional
* information mid-execution; the client below prompts interactively on STDIN
* and accepts defaults when the user presses Enter.
*
* Run against the elicitation demo server:
* php examples/client/stdio_elicitation.php
*/
require_once __DIR__.'/../../vendor/autoload.php';
use Mcp\Client;
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
use Mcp\Client\Transport\StdioTransport;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\AbstractSchemaDefinition;
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
$elicitationRequestHandler = new ElicitationRequestHandler(new class implements ElicitationCallbackInterface {
public function __invoke(ElicitRequest $request): ElicitResult
{
echo "\n[ELICIT] {$request->message}\n";
$content = [];
foreach ($request->requestedSchema->properties as $name => $definition) {
$default = $this->defaultFor($definition);
$label = $this->labelFor($definition);
if (null !== $default) {
$display = is_bool($default) ? ($default ? 'true' : 'false') : (string) $default;
echo " {$label} [{$display}]: ";
} else {
echo " {$label}: ";
}
$rawInput = fgets(\STDIN);
$input = false === $rawInput ? '' : trim($rawInput);
$value = '' === $input ? $default : $this->cast($definition, $input);
$content[$name] = $value;
}
return new ElicitResult(ElicitAction::Accept, $content);
}
private function defaultFor(object $definition): mixed
{
return match (true) {
$definition instanceof EnumSchemaDefinition => $definition->default ?? $definition->enum[0],
$definition instanceof NumberSchemaDefinition => $definition->default ?? $definition->minimum ?? ($definition->integerOnly ? 1 : 1.0),
$definition instanceof BooleanSchemaDefinition => $definition->default ?? false,
$definition instanceof StringSchemaDefinition => $definition->default ?? ('date' === $definition->format ? date('Y-m-d') : ''),
default => null,
};
}
private function labelFor(AbstractSchemaDefinition $definition): string
{
return $definition->title;
}
private function cast(object $definition, string $input): mixed
{
return match (true) {
$definition instanceof BooleanSchemaDefinition => filter_var($input, \FILTER_VALIDATE_BOOLEAN),
$definition instanceof NumberSchemaDefinition => $definition->integerOnly ? (int) $input : (float) $input,
default => $input,
};
}
});
$client = Client::builder()
->setClientInfo('STDIO Elicitation Test', '1.0.0')
->setInitTimeout(30)
->setRequestTimeout(120)
->setCapabilities(new ClientCapabilities(elicitation: true))
->addRequestHandler($elicitationRequestHandler)
->build();
$transport = new StdioTransport(
command: 'php',
args: [__DIR__.'/../server/elicitation/server.php'],
);
try {
echo "Connecting to MCP server...\n";
$client->connect($transport);
$serverInfo = $client->getServerInfo();
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
echo "Calling 'book_restaurant'...\n";
$result = $client->callTool(
name: 'book_restaurant',
arguments: ['restaurantName' => 'The Test Kitchen'],
);
echo "\nResult:\n";
foreach ($result->content as $content) {
if ($content instanceof TextContent) {
echo $content->text."\n";
}
}
echo "\nCalling 'confirm_action'...\n";
$result = $client->callTool(
name: 'confirm_action',
arguments: ['actionDescription' => 'Delete all temporary files'],
);
echo "\nResult:\n";
foreach ($result->content as $content) {
if ($content instanceof TextContent) {
echo $content->text."\n";
}
}
} catch (Throwable $e) {
echo "Error: {$e->getMessage()}\n";
echo $e->getTraceAsString()."\n";
} finally {
$client->disconnect();
}

View file

@ -0,0 +1,26 @@
<?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\Client\Handler\Request;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
/**
* Contract for callbacks used by ElicitationRequestHandler.
*
* Implementations present the requested schema to the user and collect their
* response when the server sends an elicitation/create request.
*/
interface ElicitationCallbackInterface
{
public function __invoke(ElicitRequest $request): ElicitResult;
}

View file

@ -0,0 +1,68 @@
<?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\Client\Handler\Request;
use Mcp\Exception\ElicitationException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Handler for elicitation requests from the server.
*
* The MCP server may request additional information from the user during tool
* execution. This handler wraps a user-provided callback that presents the
* requested schema to the user and returns their response.
*
* @implements RequestHandlerInterface<ElicitResult>
*
* @author Johannes Wachter <johannes@sulu.io>
*/
class ElicitationRequestHandler implements RequestHandlerInterface
{
public function __construct(
private readonly ElicitationCallbackInterface $callback,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function supports(Request $request): bool
{
return $request instanceof ElicitRequest;
}
/**
* @return Response<ElicitResult>|Error
*/
public function handle(Request $request): Response|Error
{
\assert($request instanceof ElicitRequest);
try {
$result = $this->callback->__invoke($request);
return new Response($request->getId(), $result);
} catch (ElicitationException $e) {
$this->logger->error('Elicitation failed: '.$e->getMessage(), ['exception' => $e]);
return Error::forInternalError($e->getMessage(), $request->getId());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error during elicitation', ['exception' => $e]);
return Error::forInternalError('Error while processing elicitation', $request->getId());
}
}
}

View 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;
/**
* Exception thrown when an elicitation request fails.
*
* When thrown from an elicitation callback, this exception's message will be
* included in the error response sent back to the server.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class ElicitationException extends \RuntimeException implements ExceptionInterface
{
}

View file

@ -53,6 +53,7 @@ final class MessageFactory
Schema\Request\CallToolRequest::class,
Schema\Request\CompletionCompleteRequest::class,
Schema\Request\CreateSamplingMessageRequest::class,
Schema\Request\ElicitRequest::class,
Schema\Request\GetPromptRequest::class,
Schema\Request\InitializeRequest::class,
Schema\Request\ListPromptsRequest::class,

View file

@ -0,0 +1,138 @@
<?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\Client\Handler\Request;
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
use Mcp\Exception\ElicitationException;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\PingRequest;
use Mcp\Schema\Result\ElicitResult;
use PHPUnit\Framework\TestCase;
class ElicitationRequestHandlerTest extends TestCase
{
public function testSupportsElicitRequest(): void
{
$handler = new ElicitationRequestHandler($this->callbackReturning(
new ElicitResult(ElicitAction::Decline),
));
$this->assertTrue($handler->supports($this->createElicitRequest()));
}
public function testDoesNotSupportOtherRequests(): void
{
$handler = new ElicitationRequestHandler($this->callbackReturning(
new ElicitResult(ElicitAction::Decline),
));
$ping = PingRequest::fromArray([
'jsonrpc' => '2.0',
'method' => PingRequest::getMethod(),
'id' => 'ping-1',
]);
$this->assertFalse($handler->supports($ping));
}
public function testHandleReturnsResponseOnAccept(): void
{
$result = new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']);
$handler = new ElicitationRequestHandler($this->callbackReturning($result));
$request = $this->createElicitRequest();
$response = $handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($request->getId(), $response->id);
$this->assertSame($result, $response->result);
}
public function testHandleReturnsErrorOnElicitationException(): void
{
$handler = new ElicitationRequestHandler($this->callbackThrowing(
new ElicitationException('user input unavailable'),
));
$request = $this->createElicitRequest();
$response = $handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertSame($request->getId(), $response->getId());
$this->assertSame('user input unavailable', $response->message);
}
public function testHandleReturnsGenericErrorOnThrowable(): void
{
$handler = new ElicitationRequestHandler($this->callbackThrowing(
new \RuntimeException('boom'),
));
$request = $this->createElicitRequest();
$response = $handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertSame($request->getId(), $response->getId());
$this->assertSame('Error while processing elicitation', $response->message);
}
private function createElicitRequest(): ElicitRequest
{
return ElicitRequest::fromArray([
'jsonrpc' => '2.0',
'method' => ElicitRequest::getMethod(),
'id' => 'elicit-'.uniqid(),
'params' => [
'message' => 'Please provide your name.',
'requestedSchema' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'title' => 'Name'],
],
'required' => ['name'],
],
],
]);
}
private function callbackReturning(ElicitResult $result): ElicitationCallbackInterface
{
return new class($result) implements ElicitationCallbackInterface {
public function __construct(private readonly ElicitResult $result)
{
}
public function __invoke(ElicitRequest $request): ElicitResult
{
return $this->result;
}
};
}
private function callbackThrowing(\Throwable $exception): ElicitationCallbackInterface
{
return new class($exception) implements ElicitationCallbackInterface {
public function __construct(private readonly \Throwable $exception)
{
}
public function __invoke(ElicitRequest $request): ElicitResult
{
throw $this->exception;
}
};
}
}

View file

@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Notification\CancelledNotification;
use Mcp\Schema\Notification\InitializedNotification;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\PingRequest;
use PHPUnit\Framework\TestCase;
@ -336,6 +337,22 @@ final class MessageFactoryTest extends TestCase
$this->assertInstanceOf(PingRequest::class, $results[0]);
}
public function testMakeFactoryParsesElicitationCreate(): void
{
$factory = MessageFactory::make();
$json = '{"jsonrpc": "2.0", "method": "elicitation/create", "id": 1, "params": {"message": "Your name?", "requestedSchema": {"type": "object", "properties": {"name": {"type": "string", "title": "Name"}}, "required": ["name"]}}}';
$results = $factory->create($json);
$this->assertCount(1, $results);
/** @var ElicitRequest $result */
$result = $results[0];
$this->assertInstanceOf(ElicitRequest::class, $result);
$this->assertSame('elicitation/create', $result::getMethod());
$this->assertSame('Your name?', $result->message);
$this->assertSame(1, $result->getId());
}
public function testResponseWithInvalidIdType(): void
{
$json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}';