PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.4
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.4
3.4.4 3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 All 197 releases
convertkit / vendor / wordpress / mcp-adapter / includes / Handlers / Prompts / PromptsHandler.php

PromptsHandler.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.4, 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