Skip to content

Commit 02e8637

Browse files
Add elicitation support for server-to-client user input requests (#228)
- Add ElicitAction enum (accept/decline/cancel) - Add schema definitions for elicitation fields (string, number, boolean, enum) - Add ElicitationSchema wrapper for requestedSchema - Add ElicitRequest and ElicitResult for elicitation/create method - Update ClientCapabilities to support elicitation capability - Add elicit() method to ClientGateway - Store client capabilities in session during initialization - Add comprehensive unit tests - Add elicitation example server with book_restaurant, confirm_action, and collect_feedback tools
1 parent aafac3d commit 02e8637

28 files changed

+2582
-5
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"Mcp\\Example\\Server\\ClientCommunication\\": "examples/server/client-communication/",
6060
"Mcp\\Example\\Server\\ClientLogging\\": "examples/server/client-logging/",
6161
"Mcp\\Example\\Server\\CombinedRegistration\\": "examples/server/combined-registration/",
62+
"Mcp\\Example\\Server\\Elicitation\\": "examples/server/elicitation/",
6263
"Mcp\\Example\\Server\\ComplexToolSchema\\": "examples/server/complex-tool-schema/",
6364
"Mcp\\Example\\Server\\Conformance\\": "examples/server/conformance/",
6465
"Mcp\\Example\\Server\\CustomDependencies\\": "examples/server/custom-dependencies/",

docs/examples.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,80 @@ public function formatText(
276276
string $format = 'sentence'
277277
): array
278278
```
279+
280+
### Elicitation
281+
282+
**File**: `examples/server/elicitation/`
283+
284+
**What it demonstrates:**
285+
- Server-to-client elicitation requests
286+
- Interactive user input during tool execution
287+
- Multi-field form schemas with validation
288+
- Boolean confirmation dialogs
289+
- Enum fields with human-readable labels
290+
- Handling accept/decline/cancel responses
291+
- Session persistence requirement for server-initiated requests
292+
293+
**Key Features:**
294+
```php
295+
// Check client support before eliciting
296+
if (!$context->getClientGateway()->supportsElicitation()) {
297+
return ['status' => 'error', 'message' => 'Client does not support elicitation'];
298+
}
299+
300+
// Build schema with multiple field types
301+
$schema = new ElicitationSchema(
302+
properties: [
303+
'party_size' => new NumberSchemaDefinition(
304+
title: 'Party Size',
305+
integerOnly: true,
306+
minimum: 1,
307+
maximum: 20
308+
),
309+
'date' => new StringSchemaDefinition(
310+
title: 'Reservation Date',
311+
format: 'date'
312+
),
313+
'dietary' => new EnumSchemaDefinition(
314+
title: 'Dietary Restrictions',
315+
enum: ['none', 'vegetarian', 'vegan'],
316+
enumNames: ['None', 'Vegetarian', 'Vegan']
317+
),
318+
],
319+
required: ['party_size', 'date']
320+
);
321+
322+
// Send elicitation request
323+
$result = $client->elicit(
324+
message: 'Please provide your reservation details',
325+
requestedSchema: $schema
326+
);
327+
328+
// Handle response
329+
if ($result->isAccepted()) {
330+
$data = $result->content; // User-provided data
331+
} elseif ($result->isDeclined() || $result->isCancelled()) {
332+
// User declined or cancelled
333+
}
334+
```
335+
336+
**Important Notes:**
337+
- Elicitation requires a session store (e.g., `FileSessionStore`)
338+
- Check client capabilities with `supportsElicitation()` before sending requests
339+
- Schema supports primitive types: string, number/integer, boolean, enum
340+
- String fields support format validation: date, date-time, email, uri
341+
- Users can accept (providing data), decline, or cancel requests
342+
343+
**Usage:**
344+
```bash
345+
# Interactive testing with MCP client that supports elicitation
346+
npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php
347+
348+
# Test with Goose (confirmed working by reviewer)
349+
# Or configure in Claude Desktop or other MCP clients
350+
```
351+
352+
**Example Tools:**
353+
1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields
354+
2. **confirm_action** - Simple boolean confirmation dialog
355+
3. **collect_feedback** - Rating and comments form with optional fields
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the official PHP MCP SDK.
7+
*
8+
* A collaboration between Symfony and the PHP Foundation.
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Mcp\Example\Server\Elicitation;
15+
16+
use Mcp\Capability\Attribute\McpTool;
17+
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
18+
use Mcp\Schema\Elicitation\ElicitationSchema;
19+
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
20+
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
21+
use Mcp\Schema\Elicitation\StringSchemaDefinition;
22+
use Mcp\Server\RequestContext;
23+
use Psr\Log\LoggerInterface;
24+
25+
/**
26+
* Example handlers demonstrating the elicitation feature.
27+
*
28+
* Elicitation allows servers to request additional information from users
29+
* during tool execution. The user can accept (providing data), decline,
30+
* or cancel the request.
31+
*/
32+
final class ElicitationHandlers
33+
{
34+
public function __construct(
35+
private readonly LoggerInterface $logger,
36+
) {
37+
$this->logger->info('ElicitationHandlers instantiated.');
38+
}
39+
40+
/**
41+
* Book a restaurant reservation with user elicitation.
42+
*
43+
* Demonstrates multi-field elicitation with different field types:
44+
* - Number field for party size with validation
45+
* - String field with date format for reservation date
46+
* - Enum field for dietary restrictions with human-readable labels
47+
*
48+
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}
49+
*/
50+
#[McpTool('book_restaurant', 'Book a restaurant reservation, collecting details via elicitation.')]
51+
public function bookRestaurant(RequestContext $context, string $restaurantName): array
52+
{
53+
if (!$context->getClientGateway()->supportsElicitation()) {
54+
return [
55+
'status' => 'error',
56+
'message' => 'Client does not support elicitation. Please provide reservation details (party_size, date, dietary) as tool parameters instead.',
57+
];
58+
}
59+
60+
$client = $context->getClientGateway();
61+
62+
$this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName));
63+
64+
$schema = new ElicitationSchema(
65+
properties: [
66+
'party_size' => new NumberSchemaDefinition(
67+
title: 'Party Size',
68+
integerOnly: true,
69+
description: 'Number of guests in your party',
70+
default: 2,
71+
minimum: 1,
72+
maximum: 20,
73+
),
74+
'date' => new StringSchemaDefinition(
75+
title: 'Reservation Date',
76+
description: 'Preferred date for your reservation',
77+
format: 'date',
78+
),
79+
'dietary' => new EnumSchemaDefinition(
80+
title: 'Dietary Restrictions',
81+
enum: ['none', 'vegetarian', 'vegan', 'gluten-free', 'halal', 'kosher'],
82+
description: 'Any dietary restrictions or preferences',
83+
default: 'none',
84+
enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
85+
),
86+
],
87+
required: ['party_size', 'date'],
88+
);
89+
90+
$result = $client->elicit(
91+
message: \sprintf('Please provide your reservation details for %s:', $restaurantName),
92+
requestedSchema: $schema,
93+
timeout: 120,
94+
);
95+
96+
if ($result->isDeclined()) {
97+
$this->logger->info('User declined to provide reservation details.');
98+
99+
return [
100+
'status' => 'declined',
101+
'message' => 'Reservation request was declined by user.',
102+
];
103+
}
104+
105+
if ($result->isCancelled()) {
106+
$this->logger->info('User cancelled the reservation request.');
107+
108+
return [
109+
'status' => 'cancelled',
110+
'message' => 'Reservation request was cancelled.',
111+
];
112+
}
113+
114+
$content = $result->content;
115+
if (null === $content) {
116+
throw new \RuntimeException('Expected content for accepted elicitation.');
117+
}
118+
119+
if (!isset($content['party_size']) || !isset($content['date'])) {
120+
throw new \RuntimeException('Missing required fields: party_size and date.');
121+
}
122+
123+
$partySize = (int) $content['party_size'];
124+
$date = (string) $content['date'];
125+
$dietary = (string) ($content['dietary'] ?? 'none');
126+
127+
if ($partySize < 1 || $partySize > 20) {
128+
throw new \RuntimeException(\sprintf('Invalid party size: %d. Must be between 1 and 20.', $partySize));
129+
}
130+
131+
$this->logger->info(\sprintf(
132+
'Booking confirmed: %d guests on %s with %s dietary requirements',
133+
$partySize,
134+
$date,
135+
$dietary,
136+
));
137+
138+
return [
139+
'status' => 'confirmed',
140+
'message' => \sprintf(
141+
'Reservation confirmed at %s for %d guests on %s.',
142+
$restaurantName,
143+
$partySize,
144+
$date,
145+
),
146+
'booking' => [
147+
'party_size' => $partySize,
148+
'date' => $date,
149+
'dietary' => $dietary,
150+
],
151+
];
152+
}
153+
154+
/**
155+
* Confirm an action with a simple boolean elicitation.
156+
*
157+
* Demonstrates the simplest elicitation pattern - a yes/no confirmation.
158+
*
159+
* @return array{status: string, message: string}
160+
*/
161+
#[McpTool('confirm_action', 'Request user confirmation before proceeding with an action.')]
162+
public function confirmAction(RequestContext $context, string $actionDescription): array
163+
{
164+
if (!$context->getClientGateway()->supportsElicitation()) {
165+
return [
166+
'status' => 'error',
167+
'message' => 'Client does not support elicitation. Please confirm the action explicitly in your request.',
168+
];
169+
}
170+
171+
$client = $context->getClientGateway();
172+
173+
$schema = new ElicitationSchema(
174+
properties: [
175+
'confirm' => new BooleanSchemaDefinition(
176+
title: 'Confirm',
177+
description: 'Check to confirm you want to proceed',
178+
default: false,
179+
),
180+
],
181+
required: ['confirm'],
182+
);
183+
184+
$result = $client->elicit(
185+
message: \sprintf('Are you sure you want to: %s?', $actionDescription),
186+
requestedSchema: $schema,
187+
);
188+
189+
if (!$result->isAccepted()) {
190+
return [
191+
'status' => 'not_confirmed',
192+
'message' => 'Action was not confirmed by user.',
193+
];
194+
}
195+
196+
$content = $result->content;
197+
if (null === $content) {
198+
throw new \RuntimeException('Expected content for accepted elicitation.');
199+
}
200+
201+
if (!isset($content['confirm'])) {
202+
throw new \RuntimeException('Missing required field: confirm.');
203+
}
204+
205+
$confirmed = (bool) $content['confirm'];
206+
207+
if (!$confirmed) {
208+
return [
209+
'status' => 'not_confirmed',
210+
'message' => 'User did not check the confirmation box.',
211+
];
212+
}
213+
214+
$this->logger->info(\sprintf('User confirmed action: %s', $actionDescription));
215+
216+
return [
217+
'status' => 'confirmed',
218+
'message' => \sprintf('Action confirmed: %s', $actionDescription),
219+
];
220+
}
221+
222+
/**
223+
* Collect user feedback using elicitation.
224+
*
225+
* Demonstrates elicitation with optional fields and enum with labels.
226+
*
227+
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}
228+
*/
229+
#[McpTool('collect_feedback', 'Collect user feedback via elicitation form.')]
230+
public function collectFeedback(RequestContext $context, string $topic): array
231+
{
232+
if (!$context->getClientGateway()->supportsElicitation()) {
233+
return [
234+
'status' => 'error',
235+
'message' => 'Client does not support elicitation. Please provide feedback (rating 1-5, comments) as tool parameters instead.',
236+
];
237+
}
238+
239+
$client = $context->getClientGateway();
240+
241+
$schema = new ElicitationSchema(
242+
properties: [
243+
'rating' => new EnumSchemaDefinition(
244+
title: 'Rating',
245+
enum: ['1', '2', '3', '4', '5'],
246+
description: 'Rate your experience from 1 (poor) to 5 (excellent)',
247+
enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'],
248+
),
249+
'comments' => new StringSchemaDefinition(
250+
title: 'Comments',
251+
description: 'Any additional comments or suggestions (optional)',
252+
maxLength: 500,
253+
),
254+
],
255+
required: ['rating'],
256+
);
257+
258+
$result = $client->elicit(
259+
message: \sprintf('Please provide your feedback about: %s', $topic),
260+
requestedSchema: $schema,
261+
);
262+
263+
if (!$result->isAccepted()) {
264+
return [
265+
'status' => 'skipped',
266+
'message' => 'User chose not to provide feedback.',
267+
];
268+
}
269+
270+
$content = $result->content;
271+
if (null === $content) {
272+
throw new \RuntimeException('Expected content for accepted elicitation.');
273+
}
274+
275+
if (!isset($content['rating'])) {
276+
throw new \RuntimeException('Missing required field: rating.');
277+
}
278+
279+
$rating = (string) $content['rating'];
280+
$comments = (string) ($content['comments'] ?? '');
281+
282+
$this->logger->info(\sprintf('Feedback received: rating=%s, comments=%s', $rating, $comments));
283+
284+
return [
285+
'status' => 'received',
286+
'message' => 'Thank you for your feedback!',
287+
'feedback' => [
288+
'rating' => $rating,
289+
'comments' => $comments,
290+
],
291+
];
292+
}
293+
}

0 commit comments

Comments
 (0)