Skip to content

Commit 2315af2

Browse files
[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.
1 parent d150772 commit 2315af2

10 files changed

Lines changed: 468 additions & 1 deletion

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ $client->disconnect();
210210
- **Resource Access**: Read static and dynamic resources
211211
- **Prompt Management**: List and retrieve prompt templates
212212
- **Completion Support**: Request argument completion suggestions
213+
- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests
213214

214215
### Advanced Features
215216

@@ -233,6 +234,15 @@ $client = Client::builder()
233234
->build();
234235
```
235236

237+
- **Elicitation Support**: Respond to server requests for user input
238+
```php
239+
$elicitationHandler = new ElicitationRequestHandler($myCallback);
240+
$client = Client::builder()
241+
->setCapabilities(new ClientCapabilities(elicitation: true))
242+
->addRequestHandler($elicitationHandler)
243+
->build();
244+
```
245+
236246
- **Logging Notifications**: Receive server log messages
237247
```php
238248
$loggingHandler = new LoggingNotificationHandler($myCallback);

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This roadmap is a living document that outlines the planned features and improve
55
## Goals for the First Major Release
66

77
- **Server**
8-
- [ ] Implement full support for elicitations
8+
- [x] Implement full support for elicitations
99
- [ ] Implement OAuth2 authentication for server
1010
- **Client**
1111
- [x] Implement client-side support

docs/client.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,72 @@ $client = Client::builder()
534534
> throw new \RuntimeException('Rate limit exceeded');
535535
> ```
536536
537+
### Elicitation (User Input Requests)
538+
539+
Handle server requests to elicit additional information from the user during tool
540+
execution. The server sends an `elicitation/create` request describing the fields it
541+
needs; your callback presents them to the user and returns an `ElicitResult` with one of
542+
three actions — accept (with the collected content), decline, or cancel:
543+
544+
```php
545+
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
546+
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
547+
use Mcp\Exception\ElicitationException;
548+
use Mcp\Schema\ClientCapabilities;
549+
use Mcp\Schema\Enum\ElicitAction;
550+
use Mcp\Schema\Request\ElicitRequest;
551+
use Mcp\Schema\Result\ElicitResult;
552+
553+
class ConsoleElicitationCallback implements ElicitationCallbackInterface
554+
{
555+
public function __invoke(ElicitRequest $request): ElicitResult
556+
{
557+
echo $request->message.\PHP_EOL;
558+
559+
// Present $request->requestedSchema->properties to the user and collect input.
560+
$content = [];
561+
foreach ($request->requestedSchema->properties as $name => $definition) {
562+
$answer = readline($definition->title.': ');
563+
564+
if (false === $answer) {
565+
// No input available — let the server know the user cancelled.
566+
return new ElicitResult(ElicitAction::Cancel);
567+
}
568+
569+
$content[$name] = $answer;
570+
}
571+
572+
return new ElicitResult(ElicitAction::Accept, $content);
573+
}
574+
}
575+
576+
$client = Client::builder()
577+
->setCapabilities(new ClientCapabilities(elicitation: true))
578+
->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback))
579+
->build();
580+
```
581+
582+
Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the
583+
information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request.
584+
Only the `Accept` action carries content.
585+
586+
> [!IMPORTANT]
587+
> **Error Handling in Elicitation Callbacks:**
588+
>
589+
> - **Throw `ElicitationException`** to forward a specific error message to the server
590+
> - **Any other exception** is logged but returns a generic error to the server
591+
>
592+
> ```php
593+
> // Good: Server receives "No interactive console available" message
594+
> throw new ElicitationException('No interactive console available');
595+
>
596+
> // Bad: Server receives generic "Error while processing elicitation" message
597+
> throw new \RuntimeException('No interactive console available');
598+
> ```
599+
600+
See `examples/client/stdio_elicitation.php` for a runnable example against the
601+
elicitation demo server.
602+
537603
## Error Handling
538604
539605
The client throws exceptions for various error conditions:
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
/**
13+
* STDIO Client Elicitation Example.
14+
*
15+
* This example demonstrates how a client responds to server-initiated
16+
* elicitation/create requests. The server's tools ask the user for additional
17+
* information mid-execution; the client below auto-fills sensible answers
18+
* derived from the requested schema (in a real client you would prompt the user).
19+
*
20+
* Run against the elicitation demo server:
21+
* php examples/client/stdio_elicitation.php
22+
*/
23+
24+
require_once __DIR__.'/../../vendor/autoload.php';
25+
26+
use Mcp\Client;
27+
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
28+
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
29+
use Mcp\Client\Transport\StdioTransport;
30+
use Mcp\Schema\ClientCapabilities;
31+
use Mcp\Schema\Content\TextContent;
32+
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
33+
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
34+
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
35+
use Mcp\Schema\Elicitation\StringSchemaDefinition;
36+
use Mcp\Schema\Enum\ElicitAction;
37+
use Mcp\Schema\Request\ElicitRequest;
38+
use Mcp\Schema\Result\ElicitResult;
39+
40+
$elicitationRequestHandler = new ElicitationRequestHandler(new class implements ElicitationCallbackInterface {
41+
public function __invoke(ElicitRequest $request): ElicitResult
42+
{
43+
echo "[ELICIT] {$request->message}\n";
44+
45+
$content = [];
46+
foreach ($request->requestedSchema->properties as $name => $definition) {
47+
$value = $this->answerFor($definition);
48+
$content[$name] = $value;
49+
echo " - {$name}: ".var_export($value, true)."\n";
50+
}
51+
52+
return new ElicitResult(ElicitAction::Accept, $content);
53+
}
54+
55+
private function answerFor(object $definition): mixed
56+
{
57+
return match (true) {
58+
$definition instanceof EnumSchemaDefinition => $definition->default ?? $definition->enum[0],
59+
$definition instanceof NumberSchemaDefinition => $definition->default ?? $definition->minimum ?? ($definition->integerOnly ? 1 : 1.0),
60+
$definition instanceof BooleanSchemaDefinition => $definition->default ?? true,
61+
$definition instanceof StringSchemaDefinition => $definition->default ?? ('date' === $definition->format ? '2026-06-01' : 'example'),
62+
default => 'example',
63+
};
64+
}
65+
});
66+
67+
$client = Client::builder()
68+
->setClientInfo('STDIO Elicitation Test', '1.0.0')
69+
->setInitTimeout(30)
70+
->setRequestTimeout(120)
71+
->setCapabilities(new ClientCapabilities(elicitation: true))
72+
->addRequestHandler($elicitationRequestHandler)
73+
->build();
74+
75+
$transport = new StdioTransport(
76+
command: 'php',
77+
args: [__DIR__.'/../server/elicitation/server.php'],
78+
);
79+
80+
try {
81+
echo "Connecting to MCP server...\n";
82+
$client->connect($transport);
83+
84+
$serverInfo = $client->getServerInfo();
85+
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";
86+
87+
echo "Calling 'book_restaurant'...\n";
88+
$result = $client->callTool(
89+
name: 'book_restaurant',
90+
arguments: ['restaurantName' => 'The Test Kitchen'],
91+
);
92+
93+
echo "\nResult:\n";
94+
foreach ($result->content as $content) {
95+
if ($content instanceof TextContent) {
96+
echo $content->text."\n";
97+
}
98+
}
99+
100+
echo "\nCalling 'confirm_action'...\n";
101+
$result = $client->callTool(
102+
name: 'confirm_action',
103+
arguments: ['actionDescription' => 'Delete all temporary files'],
104+
);
105+
106+
echo "\nResult:\n";
107+
foreach ($result->content as $content) {
108+
if ($content instanceof TextContent) {
109+
echo $content->text."\n";
110+
}
111+
}
112+
} catch (Throwable $e) {
113+
echo "Error: {$e->getMessage()}\n";
114+
echo $e->getTraceAsString()."\n";
115+
} finally {
116+
$client->disconnect();
117+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Client\Handler\Request;
13+
14+
use Mcp\Schema\Request\ElicitRequest;
15+
use Mcp\Schema\Result\ElicitResult;
16+
17+
/**
18+
* Contract for callbacks used by ElicitationRequestHandler.
19+
*
20+
* Implementations present the requested schema to the user and collect their
21+
* response when the server sends an elicitation/create request.
22+
*/
23+
interface ElicitationCallbackInterface
24+
{
25+
public function __invoke(ElicitRequest $request): ElicitResult;
26+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Client\Handler\Request;
13+
14+
use Mcp\Exception\ElicitationException;
15+
use Mcp\Schema\JsonRpc\Error;
16+
use Mcp\Schema\JsonRpc\Request;
17+
use Mcp\Schema\JsonRpc\Response;
18+
use Mcp\Schema\Request\ElicitRequest;
19+
use Mcp\Schema\Result\ElicitResult;
20+
use Psr\Log\LoggerInterface;
21+
use Psr\Log\NullLogger;
22+
23+
/**
24+
* Handler for elicitation requests from the server.
25+
*
26+
* The MCP server may request additional information from the user during tool
27+
* execution. This handler wraps a user-provided callback that presents the
28+
* requested schema to the user and returns their response.
29+
*
30+
* @implements RequestHandlerInterface<ElicitResult>
31+
*
32+
* @author Johannes Wachter <johannes@sulu.io>
33+
*/
34+
class ElicitationRequestHandler implements RequestHandlerInterface
35+
{
36+
public function __construct(
37+
private readonly ElicitationCallbackInterface $callback,
38+
private readonly LoggerInterface $logger = new NullLogger(),
39+
) {
40+
}
41+
42+
public function supports(Request $request): bool
43+
{
44+
return $request instanceof ElicitRequest;
45+
}
46+
47+
/**
48+
* @return Response<ElicitResult>|Error
49+
*/
50+
public function handle(Request $request): Response|Error
51+
{
52+
\assert($request instanceof ElicitRequest);
53+
54+
try {
55+
$result = $this->callback->__invoke($request);
56+
57+
return new Response($request->getId(), $result);
58+
} catch (ElicitationException $e) {
59+
$this->logger->error('Elicitation failed: '.$e->getMessage(), ['exception' => $e]);
60+
61+
return Error::forInternalError($e->getMessage(), $request->getId());
62+
} catch (\Throwable $e) {
63+
$this->logger->error('Unexpected error during elicitation', ['exception' => $e]);
64+
65+
return Error::forInternalError('Error while processing elicitation', $request->getId());
66+
}
67+
}
68+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
/**
15+
* Exception thrown when an elicitation request fails.
16+
*
17+
* When thrown from an elicitation callback, this exception's message will be
18+
* included in the error response sent back to the server.
19+
*
20+
* @author Johannes Wachter <johannes@sulu.io>
21+
*/
22+
final class ElicitationException extends \RuntimeException implements ExceptionInterface
23+
{
24+
}

src/JsonRpc/MessageFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ final class MessageFactory
5353
Schema\Request\CallToolRequest::class,
5454
Schema\Request\CompletionCompleteRequest::class,
5555
Schema\Request\CreateSamplingMessageRequest::class,
56+
Schema\Request\ElicitRequest::class,
5657
Schema\Request\GetPromptRequest::class,
5758
Schema\Request\InitializeRequest::class,
5859
Schema\Request\ListPromptsRequest::class,

0 commit comments

Comments
 (0)