diff --git a/README.md b/README.md index 8b3bccfd..18052a14 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/ROADMAP.md b/ROADMAP.md index 03e47515..5b67e14f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/client.md b/docs/client.md index 304fad77..a08a3e11 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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: diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php new file mode 100644 index 00000000..9fe13258 --- /dev/null +++ b/examples/client/stdio_elicitation.php @@ -0,0 +1,151 @@ +message}\n"; + + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $default = $this->defaultFor($definition); + $label = $this->labelFor($definition, $name); + + 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(object $definition, string $name): string + { + $title = match (true) { + $definition instanceof EnumSchemaDefinition => $definition->title, + $definition instanceof NumberSchemaDefinition => $definition->title, + $definition instanceof BooleanSchemaDefinition => $definition->title, + $definition instanceof StringSchemaDefinition => $definition->title, + default => null, + }; + + return $title ?? $name; + } + + 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(); +} diff --git a/src/Client/Handler/Request/ElicitationCallbackInterface.php b/src/Client/Handler/Request/ElicitationCallbackInterface.php new file mode 100644 index 00000000..fdaea14c --- /dev/null +++ b/src/Client/Handler/Request/ElicitationCallbackInterface.php @@ -0,0 +1,26 @@ + + * + * @author Johannes Wachter + */ +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|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()); + } + } +} diff --git a/src/Exception/ElicitationException.php b/src/Exception/ElicitationException.php new file mode 100644 index 00000000..0ef784e1 --- /dev/null +++ b/src/Exception/ElicitationException.php @@ -0,0 +1,24 @@ + + */ +final class ElicitationException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 1bb82db8..0eab08ff 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -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, diff --git a/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php new file mode 100644 index 00000000..67358717 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ElicitationRequestHandlerTest.php @@ -0,0 +1,138 @@ +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; + } + }; + } +} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index d38aabeb..9af6e342 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -17,6 +17,7 @@ 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; @@ -335,6 +336,22 @@ public function testMakeFactoryWithDefaultMessages(): void $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"}}';