|
| 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