PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.1
Elementor Website Builder – more than just a page builder v4.3.1
4.3.1 4.3.0 4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 All 454 releases
elementor / vendor / wordpress / mcp-adapter / includes / Handlers / Prompts / PromptsHandler.php

PromptsHandler.php in Elementor Website Builder – more than just a page builder 4.3.1, at vendor/wordpress/mcp-adapter/includes/Handlers/Prompts/PromptsHandler.php

658 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Prompts method handlers for MCP requests.
4 *
5 * @package McpAdapter
6 */
7
8 declare( strict_types=1 );
9
10 namespace WP\MCP\Handlers\Prompts;
11
12 use WP\MCP\Core\McpServer;
13 use WP\MCP\Domain\Utils\McpValidator;
14 use WP\MCP\Handlers\HandlerHelperTrait;
15 use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory;
16 use WP\McpSchema\Server\Prompts\DTO\GetPromptResult;
17 use WP\McpSchema\Server\Prompts\DTO\ListPromptsResult;
18 use WP\McpSchema\Server\Prompts\DTO\Prompt as PromptDto;
19 use WP\McpSchema\Server\Prompts\DTO\PromptMessage;
20
21 /**
22 * Handles prompts-related MCP methods.
23 *
24 * @since 0.5.0
25 */
26 class PromptsHandler {
27 use HandlerHelperTrait;
28
29 /**
30 * Valid content types from ContentBlockFactory.
31 *
32 * @var list<string>
33 */
34 private static array $valid_content_types = array( 'text', 'image', 'audio', 'resource_link', 'resource' );
35
36 /**
37 * Valid role values for PromptMessage.
38 *
39 * @var list<string>
40 */
41 private static array $valid_roles = array( 'user', 'assistant' );
42
43 /**
44 * Default role for messages when not specified.
45 *
46 * @var string
47 */
48 private static string $default_role = 'user';
49
50 /**
51 * The WordPress MCP instance.
52 *
53 * @var \WP\MCP\Core\McpServer
54 */
55 private McpServer $mcp;
56
57 /**
58 * Constructor.
59 *
60 * @param \WP\MCP\Core\McpServer $mcp The WordPress MCP instance.
61 */
62 public function __construct( McpServer $mcp ) {
63 $this->mcp = $mcp;
64 }
65
66 /**
67 * Handles the prompts/list request.
68 *
69 * @return \WP\McpSchema\Server\Prompts\DTO\ListPromptsResult Response with prompts list DTO.
70 */
71 public function list_prompts(): ListPromptsResult {
72 $prompts = array_values( $this->mcp->get_prompts() );
73
74 /**
75 * Filters the list of prompts before returning to the client.
76 *
77 * Use this filter to filter prompts by context, add dynamic prompts,
78 * or reorder the prompts list.
79 *
80 * @since 0.5.0
81 *
82 * @param array<\WP\McpSchema\Server\Prompts\DTO\Prompt> $prompts Array of Prompt DTOs.
83 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
84 */
85 $prompts = $this->validate_filtered_list(
86 apply_filters( 'mcp_adapter_prompts_list', $prompts, $this->mcp ),
87 $prompts,
88 'mcp_adapter_prompts_list',
89 $this->mcp->get_error_handler()
90 );
91
92 return ListPromptsResult::fromArray(
93 array(
94 'prompts' => $prompts,
95 )
96 );
97 }
98
99 /**
100 * Handles the prompts/get request.
101 *
102 * @param array $params Request parameters.
103 * @param string|int|null $request_id Optional. The request ID for JSON-RPC. Default 0.
104 *
105 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse Response with prompt execution results or error.
106 */
107 public function get_prompt( array $params, $request_id = 0 ) {
108 // Extract parameters using helper method.
109 $request_params = $this->extract_params( $params );
110
111 if ( ! isset( $request_params['name'] ) ) {
112 return McpErrorFactory::missing_parameter( $request_id, 'name' );
113 }
114
115 $prompt_name = (string) $request_params['name'];
116 $prompt_name = trim( $prompt_name );
117
118 if ( isset( $request_params['arguments'] ) && ! is_array( $request_params['arguments'] ) ) {
119 return McpErrorFactory::invalid_params( $request_id, 'arguments must be an object' );
120 }
121
122 $mcp_prompt = $this->mcp->get_mcp_prompt( $prompt_name );
123
124 if ( ! $mcp_prompt ) {
125 return McpErrorFactory::prompt_not_found( $request_id, $prompt_name );
126 }
127
128 /** @var \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt */
129 $prompt = $mcp_prompt->get_protocol_dto();
130
131 // Get the arguments for the prompt.
132 $arguments = $request_params['arguments'] ?? array();
133
134 try {
135 $permission = $mcp_prompt->check_permission( $arguments );
136 if ( true !== $permission ) {
137 $error_message = 'Access denied for prompt: ' . $prompt_name;
138 if ( is_wp_error( $permission ) ) {
139 $error_message = $permission->get_error_message();
140 }
141
142 return McpErrorFactory::permission_denied( $request_id, $error_message );
143 }
144
145 /**
146 * Filters prompt arguments before execution, or short-circuits execution entirely.
147 *
148 * Return the (optionally modified) arguments array to proceed with execution,
149 * or return a WP_Error to block execution and return an error to the client.
150 *
151 * @since 0.5.0
152 *
153 * @param array $arguments The prompt arguments.
154 * @param string $prompt_name The prompt name being retrieved.
155 * @param \WP\MCP\Domain\Prompts\McpPrompt $mcp_prompt The MCP prompt instance.
156 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
157 */
158 $arguments = apply_filters( 'mcp_adapter_pre_prompt_get', $arguments, $prompt_name, $mcp_prompt, $this->mcp );
159
160 // Allow pre-filter to short-circuit execution by returning WP_Error.
161 if ( is_wp_error( $arguments ) ) {
162 return McpErrorFactory::internal_error( $request_id, $arguments->get_error_message() );
163 }
164
165 $result = $mcp_prompt->execute( $arguments );
166
167 /**
168 * Filters the prompt execution result before normalization.
169 *
170 * Use this filter for message transformation, context injection,
171 * content enrichment, or audit logging.
172 *
173 * @since 0.5.0
174 *
175 * @param mixed|\WP_Error $result The raw execution result (may be WP_Error).
176 * @param array $arguments The prompt arguments used.
177 * @param string $prompt_name The prompt name.
178 * @param \WP\MCP\Domain\Prompts\McpPrompt $mcp_prompt The MCP prompt instance.
179 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
180 */
181 $result = apply_filters( 'mcp_adapter_prompt_get_result', $result, $arguments, $prompt_name, $mcp_prompt, $this->mcp );
182
183 if ( is_wp_error( $result ) ) {
184 $this->mcp->get_error_handler()->log(
185 'Prompt execution returned WP_Error',
186 array(
187 'prompt_name' => $prompt_name,
188 'error_code' => $result->get_error_code(),
189 'error_message' => $result->get_error_message(),
190 )
191 );
192
193 return McpErrorFactory::internal_error( $request_id, $result->get_error_message() );
194 }
195
196 return $this->normalize_result_to_dto( $result, $prompt, $prompt_name );
197 } catch ( \Throwable $e ) {
198 $this->mcp->get_error_handler()->log(
199 'Prompt execution failed',
200 array(
201 'prompt_name' => $prompt_name,
202 'arguments' => $arguments,
203 'error' => $e->getMessage(),
204 )
205 );
206
207 return McpErrorFactory::internal_error( $request_id, 'Prompt execution failed' );
208 }
209 }
210
211 // =========================================================================
212 // Result Normalization (Tiered Convenience Shortcuts)
213 // =========================================================================
214
215 /**
216 * Normalize and convert prompt execution result to GetPromptResult DTO.
217 *
218 * Supports tiered return formats:
219 * - Tier 1: Full MCP format with 'messages' array
220 * - Tier 2: Simple 'text' shorthand
221 * - Tier 3: Single message with 'role' and 'content'
222 * - Tier 4: Multi-text with 'texts' array
223 * - Tier 5: Fallback JSON encoding for arbitrary data
224 *
225 * @since 0.5.0
226 *
227 * @param array $result Raw result from prompt execution.
228 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO for description fallback.
229 * @param string $prompt_name Prompt name for logging.
230 *
231 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
232 */
233 private function normalize_result_to_dto(
234 array $result,
235 PromptDto $prompt,
236 string $prompt_name
237 ): GetPromptResult {
238 // Tier 1: Full MCP format with 'messages' array.
239 if ( isset( $result['messages'] ) && is_array( $result['messages'] ) ) {
240 return $this->normalize_tier1_messages( $result, $prompt, $prompt_name );
241 }
242
243 // Tier 2: Simple 'text' shorthand.
244 if ( isset( $result['text'] ) && is_string( $result['text'] ) ) {
245 return $this->normalize_tier2_text( $result, $prompt );
246 }
247
248 // Tier 3: Single message with 'role' key.
249 if ( isset( $result['role'] ) && isset( $result['content'] ) ) {
250 return $this->normalize_tier3_single_message( $result, $prompt, $prompt_name );
251 }
252
253 // Tier 4: Multi-text with 'texts' array.
254 if ( isset( $result['texts'] ) && is_array( $result['texts'] ) ) {
255 return $this->normalize_tier4_texts( $result, $prompt );
256 }
257
258 // Tier 5: Fallback - JSON encode arbitrary data.
259 return $this->normalize_tier5_fallback( $result, $prompt, $prompt_name );
260 }
261
262 /**
263 * Tier 1: Full MCP-compliant format with 'messages' array.
264 *
265 * @since 0.5.0
266 *
267 * @param array $result Raw result with 'messages' key.
268 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO.
269 * @param string $prompt_name Prompt name for logging.
270 *
271 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
272 */
273 private function normalize_tier1_messages(
274 array $result,
275 PromptDto $prompt,
276 string $prompt_name
277 ): GetPromptResult {
278 $message_dtos = array();
279
280 foreach ( $result['messages'] as $index => $message ) {
281 if ( ! is_array( $message ) ) {
282 $this->mcp->get_error_handler()->log(
283 'Invalid message structure in prompt result, skipping',
284 array(
285 'prompt_name' => $prompt_name,
286 'message_index' => $index,
287 'message_type' => gettype( $message ),
288 ),
289 'warning'
290 );
291 continue;
292 }
293
294 $message_dtos[] = $this->validate_and_create_message( $message, $prompt_name );
295 }
296
297 // Ensure we have at least one message.
298 if ( empty( $message_dtos ) ) {
299 $message_dtos[] = PromptMessage::fromArray(
300 array(
301 'role' => self::$default_role,
302 'content' => array(
303 'type' => 'text',
304 'text' => '(No messages returned)',
305 ),
306 )
307 );
308 }
309
310 return GetPromptResult::fromArray(
311 array(
312 'messages' => $message_dtos,
313 'description' => $result['description'] ?? $prompt->getDescription(),
314 )
315 );
316 }
317
318 /**
319 * Tier 2: Simple 'text' shorthand.
320 *
321 * Creates a single user message with text content.
322 *
323 * @since 0.5.0
324 *
325 * @param array $result Raw result with 'text' key.
326 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO.
327 *
328 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
329 */
330 private function normalize_tier2_text( array $result, PromptDto $prompt ): GetPromptResult {
331 $content = array(
332 'type' => 'text',
333 'text' => (string) $result['text'],
334 );
335
336 // Support optional annotations on the text.
337 if ( isset( $result['annotations'] ) && is_array( $result['annotations'] ) ) {
338 $content['annotations'] = $result['annotations'];
339 }
340
341 $message_dto = PromptMessage::fromArray(
342 array(
343 'role' => self::$default_role,
344 'content' => $content,
345 )
346 );
347
348 return GetPromptResult::fromArray(
349 array(
350 'messages' => array( $message_dto ),
351 'description' => $result['description'] ?? $prompt->getDescription(),
352 )
353 );
354 }
355
356 /**
357 * Tier 3: Single message with 'role' and 'content'.
358 *
359 * @since 0.5.0
360 *
361 * @param array $result Raw result with 'role' and 'content' keys.
362 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO.
363 * @param string $prompt_name Prompt name for logging.
364 *
365 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
366 */
367 private function normalize_tier3_single_message(
368 array $result,
369 PromptDto $prompt,
370 string $prompt_name
371 ): GetPromptResult {
372 $message_dto = $this->validate_and_create_message( $result, $prompt_name );
373
374 return GetPromptResult::fromArray(
375 array(
376 'messages' => array( $message_dto ),
377 'description' => $result['description'] ?? $prompt->getDescription(),
378 )
379 );
380 }
381
382 /**
383 * Tier 4: Multi-text with 'texts' array.
384 *
385 * Creates multiple messages with the same role.
386 *
387 * @since 0.5.0
388 *
389 * @param array $result Raw result with 'texts' key.
390 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO.
391 *
392 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
393 */
394 private function normalize_tier4_texts( array $result, PromptDto $prompt ): GetPromptResult {
395 $role = $this->validate_role( $result['role'] ?? self::$default_role, '' );
396 $message_dtos = array();
397
398 foreach ( $result['texts'] as $text ) {
399 if ( ! is_string( $text ) ) {
400 continue;
401 }
402
403 $message_dtos[] = PromptMessage::fromArray(
404 array(
405 'role' => $role,
406 'content' => array(
407 'type' => 'text',
408 'text' => $text,
409 ),
410 )
411 );
412 }
413
414 // Ensure we have at least one message.
415 if ( empty( $message_dtos ) ) {
416 $message_dtos[] = PromptMessage::fromArray(
417 array(
418 'role' => $role,
419 'content' => array(
420 'type' => 'text',
421 'text' => '(No texts provided)',
422 ),
423 )
424 );
425 }
426
427 return GetPromptResult::fromArray(
428 array(
429 'messages' => $message_dtos,
430 'description' => $result['description'] ?? $prompt->getDescription(),
431 )
432 );
433 }
434
435 /**
436 * Tier 5: Fallback - JSON encode arbitrary data.
437 *
438 * Used when no other tier matches. Logs an observability event.
439 *
440 * @since 0.5.0
441 *
442 * @param array $result Raw result (arbitrary structure).
443 * @param \WP\McpSchema\Server\Prompts\DTO\Prompt $prompt The prompt DTO.
444 * @param string $prompt_name Prompt name for logging.
445 *
446 * @return \WP\McpSchema\Server\Prompts\DTO\GetPromptResult
447 */
448 private function normalize_tier5_fallback(
449 array $result,
450 PromptDto $prompt,
451 string $prompt_name
452 ): GetPromptResult {
453 // Log observability event for fallback normalization.
454 $this->mcp->get_observability_handler()->record_event(
455 'prompt_result_fallback_normalization',
456 array(
457 'prompt_name' => $prompt_name,
458 'result_keys' => array_keys( $result ),
459 )
460 );
461
462 $json_content = wp_json_encode( $result, JSON_PRETTY_PRINT );
463 if ( false === $json_content ) {
464 $json_content = '{}';
465 }
466
467 $message_dto = PromptMessage::fromArray(
468 array(
469 'role' => self::$default_role,
470 'content' => array(
471 'type' => 'text',
472 'text' => $json_content,
473 ),
474 )
475 );
476
477 return GetPromptResult::fromArray(
478 array(
479 'messages' => array( $message_dto ),
480 'description' => $prompt->getDescription(),
481 )
482 );
483 }
484
485 // =========================================================================
486 // Validation Helpers
487 // =========================================================================
488
489 /**
490 * Validate message structure and create PromptMessage DTO.
491 *
492 * Validates role and content type, applying defaults where needed.
493 *
494 * @since 0.5.0
495 *
496 * @param array $message Raw message array.
497 * @param string $prompt_name Prompt name for logging.
498 *
499 * @return \WP\McpSchema\Server\Prompts\DTO\PromptMessage
500 */
501 private function validate_and_create_message( array $message, string $prompt_name ): PromptMessage {
502 // Validate and normalize role.
503 $role = $this->validate_role( $message['role'] ?? self::$default_role, $prompt_name );
504
505 // Validate and normalize content.
506 $content = $message['content'] ?? array();
507 if ( ! is_array( $content ) ) {
508 // If content is a string, wrap it as text.
509 $content = array(
510 'type' => 'text',
511 'text' => is_string( $content ) ? $content : (string) $content,
512 );
513 }
514
515 $content = $this->validate_content_type( $content, $prompt_name );
516 $content = $this->normalize_content_block( $content );
517
518 return PromptMessage::fromArray(
519 array(
520 'role' => $role,
521 'content' => $content,
522 )
523 );
524 }
525
526 /**
527 * Bring a caller-supplied content block into the shape the schema DTOs accept.
528 *
529 * Prompt messages carry the same content blocks tool results do, and reach the wire
530 * through the same DTOs, so they carry the same hazard: a `_meta` that would serialize
531 * as a JSON array where MCP declares an object.
532 *
533 * Here the cost is higher than on the tool path. A value the DTO refuses throws, and
534 * the catch in get_prompt() turns that into an error response - so a `_meta` that is
535 * not an array at all loses the whole prompt rather than the field. It is dropped so
536 * that the message survives.
537 *
538 * @since 0.6.0
539 *
540 * @param array $content Content block as the prompt returned it.
541 *
542 * @return array Content block safe to hand to PromptMessage::fromArray().
543 */
544 private function normalize_content_block( array $content ): array {
545 $block_meta = McpValidator::normalize_meta( $content['_meta'] ?? null );
546 if ( null === $block_meta ) {
547 unset( $content['_meta'] );
548 } else {
549 $content['_meta'] = $block_meta;
550 }
551
552 // EmbeddedResource takes its resource contents as given, so a nested block never
553 // reaches a DTO that could reject them. This is the only level that inspects them.
554 if ( 'resource' === ( $content['type'] ?? '' ) && isset( $content['resource'] ) && is_array( $content['resource'] ) ) {
555 $resource = $content['resource'];
556
557 $resource_meta = McpValidator::normalize_meta( $resource['_meta'] ?? null );
558 if ( null === $resource_meta ) {
559 unset( $resource['_meta'] );
560 } else {
561 $resource['_meta'] = $resource_meta;
562 }
563 $content['resource'] = $resource;
564 }
565
566 return $content;
567 }
568
569 /**
570 * Validate content type against ContentBlockFactory registry.
571 *
572 * @since 0.5.0
573 *
574 * @param array $content Content array with 'type' key.
575 * @param string $prompt_name Prompt name for logging.
576 *
577 * @return array Validated content array (may be modified if invalid type).
578 */
579 private function validate_content_type( array $content, string $prompt_name ): array {
580 $type = $content['type'] ?? null;
581
582 // Check if type is missing.
583 if ( null === $type || '' === $type ) {
584 $this->mcp->get_error_handler()->log(
585 'Missing content type in prompt result, defaulting to text',
586 array(
587 'prompt_name' => $prompt_name,
588 ),
589 'warning'
590 );
591
592 $text = isset( $content['text'] ) ? (string) $content['text'] : wp_json_encode( $content, JSON_PRETTY_PRINT );
593
594 return array(
595 'type' => 'text',
596 'text' => false === $text ? '{}' : $text,
597 );
598 }
599
600 // Check if type is valid.
601 if ( ! in_array( $type, self::$valid_content_types, true ) ) {
602 $this->mcp->get_error_handler()->log(
603 'Invalid content type in prompt result, converting to text',
604 array(
605 'prompt_name' => $prompt_name,
606 'invalid_type' => $type,
607 'valid_types' => self::$valid_content_types,
608 ),
609 'warning'
610 );
611
612 // Convert the entire content to a text representation.
613 $json_content = wp_json_encode( $content, JSON_PRETTY_PRINT );
614 if ( false === $json_content ) {
615 $json_content = '{}';
616 }
617
618 return array(
619 'type' => 'text',
620 'text' => $json_content,
621 );
622 }
623
624 // Type is valid, return content as-is (preserves annotations).
625 return $content;
626 }
627
628 /**
629 * Validate role value and apply default if invalid.
630 *
631 * @since 0.5.0
632 *
633 * @param string $role Role value to validate.
634 * @param string $prompt_name Prompt name for logging (empty to skip logging).
635 *
636 * @return string Valid role value.
637 */
638 private function validate_role( string $role, string $prompt_name ): string {
639 if ( in_array( $role, self::$valid_roles, true ) ) {
640 return $role;
641 }
642
643 if ( '' !== $prompt_name ) {
644 $this->mcp->get_error_handler()->log(
645 'Invalid role in prompt message, defaulting to user',
646 array(
647 'prompt_name' => $prompt_name,
648 'invalid_role' => $role,
649 'valid_roles' => self::$valid_roles,
650 ),
651 'warning'
652 );
653 }
654
655 return self::$default_role;
656 }
657 }
658