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.2 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 All 455 releases
elementor / vendor / wordpress / mcp-adapter / includes / Domain / Prompts / RegisterAbilityAsMcpPrompt.php

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

527 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * RegisterAbilityAsMcpPrompt class for converting WordPress abilities to MCP prompts.
5 *
6 * @package McpAdapter
7 */
8
9 declare( strict_types=1 );
10
11 namespace WP\MCP\Domain\Prompts;
12
13 use WP\MCP\Domain\Utils\McpNameSanitizer;
14 use WP\MCP\Domain\Utils\McpValidator;
15 use WP\MCP\Domain\Utils\SchemaTransformer;
16 use WP\McpSchema\Server\Prompts\DTO\Prompt as PromptDto;
17 use WP\McpSchema\Server\Prompts\DTO\PromptArgument;
18 use WP_Error;
19
20 /**
21 * Converts WordPress abilities to MCP prompts according to the specification.
22 *
23 * This class extracts prompt data from ability properties and converts the JSON Schema
24 * input_schema to MCP prompt arguments format.
25 *
26 * Schema Handling:
27 * - Object schemas with properties: Each property becomes a PromptArgument
28 * - Flattened schemas (type: string, number, etc.): Wrapped as single argument named "input"
29 * - Empty/null schemas: No arguments
30 * - Complex schemas (oneOf/anyOf): Treated as no arguments (documented limitation)
31 *
32 * Example ability registration:
33 * wp_register_ability(
34 * 'prompts/code-review',
35 * array(
36 * 'label' => 'Code Review Prompt',
37 * 'description' => 'Generate code review prompt',
38 * 'input_schema' => array(
39 * 'type' => 'object',
40 * 'properties' => array(
41 * 'code' => array('type' => 'string', 'description' => 'Code to review'),
42 * ),
43 * 'required' => array('code'),
44 * ),
45 * 'meta' => array(
46 * 'mcp' => array('public' => true, 'type' => 'prompt'),
47 * 'annotations' => array(...)
48 * )
49 * )
50 * );
51 *
52 * @since 0.5.0
53 */
54 class RegisterAbilityAsMcpPrompt {
55
56 /**
57 * The WordPress ability instance.
58 *
59 * @var \WP_Ability
60 */
61 private \WP_Ability $ability;
62
63 /**
64 * Tracks whether input_schema was transformed from flattened to object format.
65 *
66 * @since 0.5.0
67 *
68 * @var bool
69 */
70 private bool $schema_was_transformed = false;
71
72 /**
73 * The wrapper property name used when transforming flattened schemas.
74 *
75 * @since 0.5.0
76 *
77 * @var string|null
78 */
79 private ?string $schema_wrapper_property = null;
80
81 /**
82 * Tracks the source of prompt arguments.
83 *
84 * Possible values:
85 * - 'explicit': Arguments came from ability.meta.mcp.arguments
86 * - 'schema': Arguments were auto-converted from ability.input_schema
87 * - null: No arguments present
88 *
89 * @since 0.5.0
90 *
91 * @var string|null
92 */
93 private ?string $arguments_source = null;
94
95 /**
96 * Constructor.
97 *
98 * @param \WP_Ability $ability The ability.
99 */
100 private function __construct( \WP_Ability $ability ) {
101 $this->ability = $ability;
102 }
103
104 /**
105 * Make a new instance of the class.
106 *
107 * @param \WP_Ability $ability The ability.
108 *
109 * @return \WP\McpSchema\Server\Prompts\DTO\Prompt|\WP_Error Returns Prompt DTO or WP_Error if validation fails.
110 */
111 public static function make( \WP_Ability $ability ) {
112 $prompt = new self( $ability );
113
114 return $prompt->get_prompt();
115 }
116
117 /**
118 * Get the MCP prompt instance.
119 *
120 * @return \WP\McpSchema\Server\Prompts\DTO\Prompt|\WP_Error Prompt DTO or WP_Error if validation fails.
121 * @since 0.5.0
122 *
123 */
124 private function get_prompt() {
125 $built = $this->build_prompt_data();
126
127 // Propagate WP_Error from argument validation.
128 if ( is_wp_error( $built ) ) {
129 return $built;
130 }
131
132 try {
133 return PromptDto::fromArray( $built['prompt_data'] );
134 } catch ( \Throwable $e ) {
135 return new WP_Error( 'mcp_prompt_schema_invalid', $e->getMessage() );
136 }
137 }
138
139 /**
140 * Build Prompt DTO data and adapter metadata.
141 *
142 * @return array{prompt_data: array<string, mixed>, adapter_meta: array<string, mixed>}|\WP_Error
143 * @since 0.5.0
144 *
145 */
146 private function build_prompt_data() {
147 $data = $this->get_data();
148
149 // Propagate WP_Error from argument validation.
150 if ( is_wp_error( $data ) ) {
151 return $data;
152 }
153
154 // Get ability meta for icons and user _meta extraction.
155 $ability_meta = $this->ability->get_meta();
156 $mcp_meta = $ability_meta['mcp'] ?? array();
157
158 // Map icons from ability.meta.mcp.icons if present.
159 // Uses same pattern as tools/resources for consistency.
160 if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) {
161 $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] );
162 if ( ! empty( $icons_result['valid'] ) ) {
163 $data['icons'] = $icons_result['valid'];
164 }
165 }
166
167 // Build adapter metadata, tracking transformation when it occurred.
168 $adapter_meta = array(
169 'ability' => $this->ability->get_name(),
170 );
171
172 // Track arguments source when arguments are present.
173 if ( null !== $this->arguments_source ) {
174 $adapter_meta['arguments_source'] = $this->arguments_source;
175 }
176
177 // Record transformation metadata when schema was wrapped (matches tool behavior).
178 // Only relevant when arguments_source is 'schema'.
179 if ( $this->schema_was_transformed && 'schema' === $this->arguments_source ) {
180 $adapter_meta['input_schema_transformed'] = true;
181 $adapter_meta['input_schema_wrapper'] = $this->schema_wrapper_property;
182 }
183
184 // Preserve user-provided _meta from ability.meta.mcp._meta.
185 $prompt_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null );
186 if ( null !== $prompt_meta ) {
187 $data['_meta'] = $prompt_meta;
188 }
189
190 return array(
191 'prompt_data' => $data,
192 'adapter_meta' => $adapter_meta,
193 );
194 }
195
196 /**
197 * Get the MCP prompt data array.
198 *
199 * Per MCP 2025-11-25 specification, Prompt objects do NOT support annotations at the
200 * template level. Annotations are only supported on content blocks inside prompt messages
201 * (messages[].content.annotations).
202 *
203 * Arguments Resolution:
204 * 1. If `ability.meta.mcp.arguments` is defined and non-empty, use it directly (explicit override)
205 * 2. Otherwise, auto-convert from `ability.input_schema`
206 *
207 * This follows the `mcp.*` override pattern used elsewhere (mcp.uri, mcp.icons, mcp.annotations).
208 *
209 * @return array<string,mixed>|\WP_Error Prompt data array, or WP_Error if explicit arguments are invalid.
210 * @since 0.5.0
211 *
212 */
213 private function get_data() {
214 $prompt_name = $this->resolve_prompt_name();
215 if ( is_wp_error( $prompt_name ) ) {
216 return $prompt_name;
217 }
218
219 $prompt_data = array(
220 'name' => $prompt_name,
221 );
222
223 // Add optional title from ability label.
224 $label = trim( $this->ability->get_label() );
225 if ( ! empty( $label ) ) {
226 $prompt_data['title'] = $label;
227 }
228
229 // Add optional description.
230 $description = trim( $this->ability->get_description() );
231 if ( ! empty( $description ) ) {
232 $prompt_data['description'] = $description;
233 }
234
235 // Check for explicit mcp.arguments override first.
236 $explicit_arguments = $this->get_explicit_arguments();
237 if ( is_array( $explicit_arguments ) && ! empty( $explicit_arguments ) ) {
238 $arguments = $this->convert_explicit_arguments( $explicit_arguments );
239 if ( is_wp_error( $arguments ) ) {
240 return $arguments;
241 }
242 if ( ! empty( $arguments ) ) {
243 $prompt_data['arguments'] = $arguments;
244 $this->arguments_source = 'explicit';
245 }
246
247 return $prompt_data;
248 }
249
250 // Fall back to auto-converting from input_schema.
251 $input_schema = $this->ability->get_input_schema();
252 if ( ! empty( $input_schema ) ) {
253 // Use SchemaTransformer to handle flattened schemas (consistent with tool behavior).
254 $transform = SchemaTransformer::transform_to_object_schema( $input_schema );
255
256 // Track transformation state for _meta.
257 $this->schema_was_transformed = $transform['was_transformed'];
258 $this->schema_wrapper_property = $transform['wrapper_property'];
259
260 $arguments = $this->convert_input_schema_to_arguments( $transform['schema'] );
261 if ( ! empty( $arguments ) ) {
262 $prompt_data['arguments'] = $arguments;
263 $this->arguments_source = 'schema';
264 }
265 }
266
267 return $prompt_data;
268 }
269
270 /**
271 * Get explicit arguments from ability meta.mcp.arguments.
272 *
273 * @return list<array<string,mixed>>|null Explicit arguments array or null if not defined.
274 * @since 0.5.0
275 *
276 */
277 private function get_explicit_arguments(): ?array {
278 $meta = $this->ability->get_meta();
279 if ( ! isset( $meta['mcp'] ) || ! is_array( $meta['mcp'] ) ) {
280 return null;
281 }
282
283 $mcp = $meta['mcp'];
284 if ( ! isset( $mcp['arguments'] ) || ! is_array( $mcp['arguments'] ) ) {
285 return null;
286 }
287
288 return array_values( $mcp['arguments'] );
289 }
290
291 /**
292 * Convert and validate explicit arguments from ability.meta.mcp.arguments.
293 *
294 * Per MCP 2025-11-25 specification, PromptArgument has:
295 * - name (string, required): Argument identifier
296 * - title (string, optional): Human-readable display name
297 * - description (string, optional): Human-readable description
298 * - required (boolean, optional): Whether the argument must be provided
299 *
300 * @param list<array<string,mixed>> $explicit_arguments User-defined arguments array.
301 *
302 * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|\WP_Error PromptArgument DTOs or WP_Error.
303 * @since 0.5.0
304 *
305 */
306 private function convert_explicit_arguments( array $explicit_arguments ) {
307 $arguments = array();
308
309 foreach ( $explicit_arguments as $index => $arg ) {
310 if ( ! is_array( $arg ) ) {
311 return new WP_Error(
312 'mcp_prompt_invalid_argument',
313 sprintf(
314 /* translators: 1: argument index, 2: ability name */
315 __( 'Argument at index %1$d must be an array for ability "%2$s".', 'mcp-adapter' ),
316 $index,
317 $this->ability->get_name()
318 )
319 );
320 }
321
322 // Validate required 'name' field.
323 if ( ! isset( $arg['name'] ) || ! is_string( $arg['name'] ) || '' === trim( $arg['name'] ) ) {
324 return new WP_Error(
325 'mcp_prompt_argument_missing_name',
326 sprintf(
327 /* translators: 1: argument index, 2: ability name */
328 __( 'Argument at index %1$d is missing required "name" field for ability "%2$s".', 'mcp-adapter' ),
329 $index,
330 $this->ability->get_name()
331 )
332 );
333 }
334
335 $argument_data = array(
336 'name' => trim( $arg['name'] ),
337 );
338
339 // Map optional 'title' field.
340 if ( isset( $arg['title'] ) && is_string( $arg['title'] ) && '' !== trim( $arg['title'] ) ) {
341 $argument_data['title'] = trim( $arg['title'] );
342 }
343
344 // Map optional 'description' field.
345 if ( isset( $arg['description'] ) && is_string( $arg['description'] ) && '' !== trim( $arg['description'] ) ) {
346 $argument_data['description'] = trim( $arg['description'] );
347 }
348
349 // Map optional 'required' field (only emit when true, per existing pattern).
350 if ( isset( $arg['required'] ) && true === $arg['required'] ) {
351 $argument_data['required'] = true;
352 }
353
354 $arguments[] = PromptArgument::fromArray( $argument_data );
355 }
356
357 return $arguments;
358 }
359
360 /**
361 * Convert JSON Schema input_schema to MCP prompt arguments format.
362 *
363 * Converts from WordPress Abilities JSON Schema format:
364 * {
365 * "type": "object",
366 * "properties": {
367 * "topic": {"type": "string", "title": "Topic", "description": "..."},
368 * "tone": {"type": "string", "description": "..."}
369 * },
370 * "required": ["topic"]
371 * }
372 *
373 * To MCP prompt arguments format:
374 * [
375 * {"name": "topic", "title": "Topic", "description": "...", "required": true},
376 * {"name": "tone", "description": "..."}
377 * ]
378 *
379 * Note: `required` is only emitted when true; optional arguments omit the field entirely.
380 *
381 * @param array<string,mixed> $input_schema The JSON Schema from ability.
382 *
383 * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument> Argument DTO list.
384 * @since 0.5.0
385 *
386 */
387 private function convert_input_schema_to_arguments( array $input_schema ): array {
388 $arguments = array();
389
390 // Ensure we have properties to convert.
391 if ( empty( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) {
392 return $arguments;
393 }
394
395 // Get the list of required properties.
396 $required_fields = array();
397 if ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) ) {
398 $required_fields = $input_schema['required'];
399 }
400
401 // Convert each property to an MCP argument.
402 foreach ( $input_schema['properties'] as $property_name => $property_schema ) {
403 if ( ! is_array( $property_schema ) ) {
404 continue;
405 }
406
407 $is_required = in_array( $property_name, $required_fields, true );
408
409 $argument_data = array(
410 'name' => $property_name,
411 );
412
413 // Map JSON Schema title to PromptArgument.title when present.
414 if ( ! empty( $property_schema['title'] ) && is_string( $property_schema['title'] ) ) {
415 $argument_data['title'] = $property_schema['title'];
416 }
417
418 // Map JSON Schema description to PromptArgument.description when present.
419 if ( ! empty( $property_schema['description'] ) && is_string( $property_schema['description'] ) ) {
420 $argument_data['description'] = $property_schema['description'];
421 }
422
423 // Only emit required when true; omit for optional arguments.
424 if ( $is_required ) {
425 $argument_data['required'] = true;
426 }
427
428 $arguments[] = PromptArgument::fromArray( $argument_data );
429 }
430
431 return $arguments;
432 }
433
434 /**
435 * Resolve the MCP prompt name from ability.
436 *
437 * Sanitizes the ability name to MCP-valid format, applies filter, and validates result.
438 *
439 * @since 0.5.0
440 *
441 * @return string|\WP_Error Valid prompt name or error.
442 */
443 private function resolve_prompt_name() {
444 // Sanitize ability name to MCP-valid format.
445 $sanitized_name = McpNameSanitizer::sanitize_name( $this->ability->get_name() );
446
447 if ( is_wp_error( $sanitized_name ) ) {
448 return $sanitized_name;
449 }
450
451 /**
452 * Filters the MCP prompt name derived from an ability.
453 *
454 * @since 0.5.0
455 *
456 * @param string $name The sanitized prompt name.
457 * @param \WP_Ability $ability The source ability instance.
458 */
459 $filtered_name = apply_filters( 'mcp_adapter_prompt_name', $sanitized_name, $this->ability );
460
461 // Validate post-filter (in case filter broke it).
462 if ( ! is_string( $filtered_name ) || ! McpValidator::validate_name( $filtered_name ) ) {
463 return new WP_Error(
464 'mcp_prompt_name_filter_invalid',
465 sprintf(
466 /* translators: %s: invalid prompt name returned by filter */
467 __( 'Filter returned invalid MCP prompt name: %s', 'mcp-adapter' ),
468 is_string( $filtered_name ) ? $filtered_name : gettype( $filtered_name )
469 )
470 );
471 }
472
473 return $filtered_name;
474 }
475
476 /**
477 * Build a clean Prompt DTO and adapter metadata for internal wiring.
478 *
479 * This method returns a protocol-only Prompt DTO and provides the adapter metadata
480 * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution
481 * wiring to protocol surfaces.
482 *
483 * @param \WP_Ability $ability The ability.
484 *
485 * @return array{prompt: \WP\McpSchema\Server\Prompts\DTO\Prompt, adapter_meta: array<string, mixed>}|\WP_Error
486 * @since 0.5.0
487 *
488 */
489 public static function build( \WP_Ability $ability ) {
490 $prompt = new self( $ability );
491 $data = $prompt->build_prompt_data();
492
493 if ( is_wp_error( $data ) ) {
494 return $data;
495 }
496
497 try {
498 $prompt_dto = PromptDto::fromArray( $data['prompt_data'] );
499 } catch ( \Throwable $e ) {
500 return new WP_Error(
501 'mcp_prompt_dto_creation_failed',
502 sprintf(
503 /* translators: %s: error message */
504 __( 'Failed to create Prompt DTO for ability %1$s: %2$s', 'mcp-adapter' ),
505 $ability->get_name(),
506 $e->getMessage()
507 ),
508 array( 'exception' => $e )
509 );
510 }
511
512 // Optional deep validation if enabled.
513 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
514 if ( $mcp_validation_enabled ) {
515 $validation_result = McpPromptValidator::validate_prompt_dto( $prompt_dto );
516 if ( is_wp_error( $validation_result ) ) {
517 return $validation_result;
518 }
519 }
520
521 return array(
522 'prompt' => $prompt_dto,
523 'adapter_meta' => $data['adapter_meta'],
524 );
525 }
526 }
527