Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"Mcp\\Example\\Server\\ClientCommunication\\": "examples/server/client-communication/",
"Mcp\\Example\\Server\\ClientLogging\\": "examples/server/client-logging/",
"Mcp\\Example\\Server\\CombinedRegistration\\": "examples/server/combined-registration/",
"Mcp\\Example\\Server\\Elicitation\\": "examples/server/elicitation/",
"Mcp\\Example\\Server\\ComplexToolSchema\\": "examples/server/complex-tool-schema/",
"Mcp\\Example\\Server\\Conformance\\": "examples/server/conformance/",
"Mcp\\Example\\Server\\CustomDependencies\\": "examples/server/custom-dependencies/",
Expand Down
77 changes: 77 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,80 @@ public function formatText(
string $format = 'sentence'
): array
```

### Elicitation

**File**: `examples/server/elicitation/`

**What it demonstrates:**
- Server-to-client elicitation requests
- Interactive user input during tool execution
- Multi-field form schemas with validation
- Boolean confirmation dialogs
- Enum fields with human-readable labels
- Handling accept/decline/cancel responses
- Session persistence requirement for server-initiated requests

**Key Features:**
```php
// Check client support before eliciting
if (!$context->getClientGateway()->supportsElicitation()) {
return ['status' => 'error', 'message' => 'Client does not support elicitation'];
}

// Build schema with multiple field types
$schema = new ElicitationSchema(
properties: [
'party_size' => new NumberSchemaDefinition(
title: 'Party Size',
integerOnly: true,
minimum: 1,
maximum: 20
),
'date' => new StringSchemaDefinition(
title: 'Reservation Date',
format: 'date'
),
'dietary' => new EnumSchemaDefinition(
title: 'Dietary Restrictions',
enum: ['none', 'vegetarian', 'vegan'],
enumNames: ['None', 'Vegetarian', 'Vegan']
),
],
required: ['party_size', 'date']
);

// Send elicitation request
$result = $client->elicit(
message: 'Please provide your reservation details',
requestedSchema: $schema
);

// Handle response
if ($result->isAccepted()) {
$data = $result->content; // User-provided data
} elseif ($result->isDeclined() || $result->isCancelled()) {
// User declined or cancelled
}
```

**Important Notes:**
- Elicitation requires a session store (e.g., `FileSessionStore`)
- Check client capabilities with `supportsElicitation()` before sending requests
- Schema supports primitive types: string, number/integer, boolean, enum
- String fields support format validation: date, date-time, email, uri
- Users can accept (providing data), decline, or cancel requests

**Usage:**
```bash
# Interactive testing with MCP client that supports elicitation
npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php

# Test with Goose (confirmed working by reviewer)
# Or configure in Claude Desktop or other MCP clients
```

**Example Tools:**
1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields
2. **confirm_action** - Simple boolean confirmation dialog
3. **collect_feedback** - Rating and comments form with optional fields
293 changes: 293 additions & 0 deletions examples/server/elicitation/ElicitationHandlers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
<?php

declare(strict_types=1);

/*
* 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\Example\Server\Elicitation;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Server\RequestContext;
use Psr\Log\LoggerInterface;

/**
* Example handlers demonstrating the elicitation feature.
*
* Elicitation allows servers to request additional information from users
* during tool execution. The user can accept (providing data), decline,
* or cancel the request.
*/
final class ElicitationHandlers
{
public function __construct(
private readonly LoggerInterface $logger,
) {
$this->logger->info('ElicitationHandlers instantiated.');
}

/**
* Book a restaurant reservation with user elicitation.
*
* Demonstrates multi-field elicitation with different field types:
* - Number field for party size with validation
* - 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}}
*/
#[McpTool('book_restaurant', 'Book a restaurant reservation, collecting details via elicitation.')]
public function bookRestaurant(RequestContext $context, string $restaurantName): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
'status' => 'error',
'message' => 'Client does not support elicitation. Please provide reservation details (party_size, date, dietary) as tool parameters instead.',
];
}

$client = $context->getClientGateway();

$this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName));

$schema = new ElicitationSchema(
properties: [
'party_size' => new NumberSchemaDefinition(
title: 'Party Size',
integerOnly: true,
description: 'Number of guests in your party',
default: 2,
minimum: 1,
maximum: 20,
),
'date' => new StringSchemaDefinition(
title: 'Reservation Date',
description: 'Preferred date for your reservation',
format: 'date',
),
'dietary' => new EnumSchemaDefinition(
title: 'Dietary Restrictions',
enum: ['none', 'vegetarian', 'vegan', 'gluten-free', 'halal', 'kosher'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from DX point of view i think it would be great to support

enum: ['none', 'vegetarian'], // with values being name and value
// or
enum: ['None' => 'none', 'Vegetarian' => 'vegetarian'], // with index being name and values being value
// or
enum: Diet::class, // with Diet being a backed enum

follow up tho 👍

description: 'Any dietary restrictions or preferences',
default: 'none',
enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
),
],
required: ['party_size', 'date'],
);

$result = $client->elicit(
message: \sprintf('Please provide your reservation details for %s:', $restaurantName),
requestedSchema: $schema,
timeout: 120,
);

if ($result->isDeclined()) {
$this->logger->info('User declined to provide reservation details.');

return [
'status' => 'declined',
'message' => 'Reservation request was declined by user.',
];
}

if ($result->isCancelled()) {
$this->logger->info('User cancelled the reservation request.');

return [
'status' => 'cancelled',
'message' => 'Reservation request was cancelled.',
];
}

$content = $result->content;
if (null === $content) {
throw new \RuntimeException('Expected content for accepted elicitation.');
}

if (!isset($content['party_size']) || !isset($content['date'])) {
throw new \RuntimeException('Missing required fields: party_size and date.');
}

$partySize = (int) $content['party_size'];
$date = (string) $content['date'];
$dietary = (string) ($content['dietary'] ?? 'none');

if ($partySize < 1 || $partySize > 20) {
throw new \RuntimeException(\sprintf('Invalid party size: %d. Must be between 1 and 20.', $partySize));
}

$this->logger->info(\sprintf(
'Booking confirmed: %d guests on %s with %s dietary requirements',
$partySize,
$date,
$dietary,
));

return [
'status' => 'confirmed',
'message' => \sprintf(
'Reservation confirmed at %s for %d guests on %s.',
$restaurantName,
$partySize,
$date,
),
'booking' => [
'party_size' => $partySize,
'date' => $date,
'dietary' => $dietary,
],
];
}

/**
* Confirm an action with a simple boolean elicitation.
*
* Demonstrates the simplest elicitation pattern - a yes/no confirmation.
*
* @return array{status: string, message: string}
*/
#[McpTool('confirm_action', 'Request user confirmation before proceeding with an action.')]
public function confirmAction(RequestContext $context, string $actionDescription): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
'status' => 'error',
'message' => 'Client does not support elicitation. Please confirm the action explicitly in your request.',
];
}

$client = $context->getClientGateway();

$schema = new ElicitationSchema(
properties: [
'confirm' => new BooleanSchemaDefinition(
title: 'Confirm',
description: 'Check to confirm you want to proceed',
default: false,
),
],
required: ['confirm'],
);

$result = $client->elicit(
message: \sprintf('Are you sure you want to: %s?', $actionDescription),
requestedSchema: $schema,
);

if (!$result->isAccepted()) {
return [
'status' => 'not_confirmed',
'message' => 'Action was not confirmed by user.',
];
}

$content = $result->content;
if (null === $content) {
throw new \RuntimeException('Expected content for accepted elicitation.');
}

if (!isset($content['confirm'])) {
throw new \RuntimeException('Missing required field: confirm.');
}

$confirmed = (bool) $content['confirm'];

if (!$confirmed) {
return [
'status' => 'not_confirmed',
'message' => 'User did not check the confirmation box.',
];
}

$this->logger->info(\sprintf('User confirmed action: %s', $actionDescription));

return [
'status' => 'confirmed',
'message' => \sprintf('Action confirmed: %s', $actionDescription),
];
}

/**
* Collect user feedback using elicitation.
*
* Demonstrates elicitation with optional fields and enum with labels.
*
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}
*/
#[McpTool('collect_feedback', 'Collect user feedback via elicitation form.')]
public function collectFeedback(RequestContext $context, string $topic): array
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
'status' => 'error',
'message' => 'Client does not support elicitation. Please provide feedback (rating 1-5, comments) as tool parameters instead.',
];
}

$client = $context->getClientGateway();

$schema = new ElicitationSchema(
properties: [
'rating' => new EnumSchemaDefinition(
title: 'Rating',
enum: ['1', '2', '3', '4', '5'],
description: 'Rate your experience from 1 (poor) to 5 (excellent)',
enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'],
),
'comments' => new StringSchemaDefinition(
title: 'Comments',
description: 'Any additional comments or suggestions (optional)',
maxLength: 500,
),
],
required: ['rating'],
);

$result = $client->elicit(
message: \sprintf('Please provide your feedback about: %s', $topic),
requestedSchema: $schema,
);

if (!$result->isAccepted()) {
return [
'status' => 'skipped',
'message' => 'User chose not to provide feedback.',
];
}

$content = $result->content;
if (null === $content) {
throw new \RuntimeException('Expected content for accepted elicitation.');
}

if (!isset($content['rating'])) {
throw new \RuntimeException('Missing required field: rating.');
}

$rating = (string) $content['rating'];
$comments = (string) ($content['comments'] ?? '');

$this->logger->info(\sprintf('Feedback received: rating=%s, comments=%s', $rating, $comments));

return [
'status' => 'received',
'message' => 'Thank you for your feedback!',
'feedback' => [
'rating' => $rating,
'comments' => $comments,
],
];
}
}
Loading