PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wordpress / mcp-adapter / includes / Domain / Prompts / RegisterAbilityAsMcpPrompt.php

RegisterAbilityAsMcpPrompt.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wordpress/mcp-adapter/includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php

530 lines 15.5 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 = array();
186 if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) {
187 $prompt_meta = $mcp_meta['_meta'];
188 }
189 if ( ! empty( $prompt_meta ) ) {
190 $data['_meta'] = $prompt_meta;
191 }
192
193 return array(
194 'prompt_data' => $data,
195 'adapter_meta' => $adapter_meta,
196 );
197 }
198
199 /**
200 * Get the MCP prompt data array.
201 *
202 * Per MCP 2025-11-25 specification, Prompt objects do NOT support annotations at the
203 * template level. Annotations are only supported on content blocks inside prompt messages
204 * (messages[].content.annotations).
205 *
206 * Arguments Resolution:
207 * 1. If `ability.meta.mcp.arguments` is defined and non-empty, use it directly (explicit override)
208 * 2. Otherwise, auto-convert from `ability.input_schema`
209 *
210 * This follows the `mcp.*` override pattern used elsewhere (mcp.uri, mcp.icons, mcp.annotations).
211 *
212 * @return array<string,mixed>|\WP_Error Prompt data array, or WP_Error if explicit arguments are invalid.
213 * @since 0.5.0
214 *
215 */
216 private function get_data() {
217 $prompt_name = $this->resolve_prompt_name();
218 if ( is_wp_error( $prompt_name ) ) {
219 return $prompt_name;
220 }
221
222 $prompt_data = array(
223 'name' => $prompt_name,
224 );
225
226 // Add optional title from ability label.
227 $label = trim( $this->ability->get_label() );
228 if ( ! empty( $label ) ) {
229 $prompt_data['title'] = $label;
230 }
231
232 // Add optional description.
233 $description = trim( $this->ability->get_description() );
234 if ( ! empty( $description ) ) {
235 $prompt_data['description'] = $description;
236 }
237
238 // Check for explicit mcp.arguments override first.
239 $explicit_arguments = $this->get_explicit_arguments();
240 if ( is_array( $explicit_arguments ) && ! empty( $explicit_arguments ) ) {
241 $arguments = $this->convert_explicit_arguments( $explicit_arguments );
242 if ( is_wp_error( $arguments ) ) {
243 return $arguments;
244 }
245 if ( ! empty( $arguments ) ) {
246 $prompt_data['arguments'] = $arguments;
247 $this->arguments_source = 'explicit';
248 }
249
250 return $prompt_data;
251 }
252
253 // Fall back to auto-converting from input_schema.
254 $input_schema = $this->ability->get_input_schema();
255 if ( ! empty( $input_schema ) ) {
256 // Use SchemaTransformer to handle flattened schemas (consistent with tool behavior).
257 $transform = SchemaTransformer::transform_to_object_schema( $input_schema );
258
259 // Track transformation state for _meta.
260 $this->schema_was_transformed = $transform['was_transformed'];
261 $this->schema_wrapper_property = $transform['wrapper_property'];
262
263 $arguments = $this->convert_input_schema_to_arguments( $transform['schema'] );
264 if ( ! empty( $arguments ) ) {
265 $prompt_data['arguments'] = $arguments;
266 $this->arguments_source = 'schema';
267 }
268 }
269
270 return $prompt_data;
271 }
272
273 /**
274 * Get explicit arguments from ability meta.mcp.arguments.
275 *
276 * @return list<array<string,mixed>>|null Explicit arguments array or null if not defined.
277 * @since 0.5.0
278 *
279 */
280 private function get_explicit_arguments(): ?array {
281 $meta = $this->ability->get_meta();
282 if ( ! isset( $meta['mcp'] ) || ! is_array( $meta['mcp'] ) ) {
283 return null;
284 }
285
286 $mcp = $meta['mcp'];
287 if ( ! isset( $mcp['arguments'] ) || ! is_array( $mcp['arguments'] ) ) {
288 return null;
289 }
290
291 return array_values( $mcp['arguments'] );
292 }
293
294 /**
295 * Convert and validate explicit arguments from ability.meta.mcp.arguments.
296 *
297 * Per MCP 2025-11-25 specification, PromptArgument has:
298 * - name (string, required): Argument identifier
299 * - title (string, optional): Human-readable display name
300 * - description (string, optional): Human-readable description
301 * - required (boolean, optional): Whether the argument must be provided
302 *
303 * @param list<array<string,mixed>> $explicit_arguments User-defined arguments array.
304 *
305 * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument>|\WP_Error PromptArgument DTOs or WP_Error.
306 * @since 0.5.0
307 *
308 */
309 private function convert_explicit_arguments( array $explicit_arguments ) {
310 $arguments = array();
311
312 foreach ( $explicit_arguments as $index => $arg ) {
313 if ( ! is_array( $arg ) ) {
314 return new WP_Error(
315 'mcp_prompt_invalid_argument',
316 sprintf(
317 /* translators: 1: argument index, 2: ability name */
318 __( 'Argument at index %1$d must be an array for ability "%2$s".', 'mcp-adapter' ),
319 $index,
320 $this->ability->get_name()
321 )
322 );
323 }
324
325 // Validate required 'name' field.
326 if ( ! isset( $arg['name'] ) || ! is_string( $arg['name'] ) || '' === trim( $arg['name'] ) ) {
327 return new WP_Error(
328 'mcp_prompt_argument_missing_name',
329 sprintf(
330 /* translators: 1: argument index, 2: ability name */
331 __( 'Argument at index %1$d is missing required "name" field for ability "%2$s".', 'mcp-adapter' ),
332 $index,
333 $this->ability->get_name()
334 )
335 );
336 }
337
338 $argument_data = array(
339 'name' => trim( $arg['name'] ),
340 );
341
342 // Map optional 'title' field.
343 if ( isset( $arg['title'] ) && is_string( $arg['title'] ) && '' !== trim( $arg['title'] ) ) {
344 $argument_data['title'] = trim( $arg['title'] );
345 }
346
347 // Map optional 'description' field.
348 if ( isset( $arg['description'] ) && is_string( $arg['description'] ) && '' !== trim( $arg['description'] ) ) {
349 $argument_data['description'] = trim( $arg['description'] );
350 }
351
352 // Map optional 'required' field (only emit when true, per existing pattern).
353 if ( isset( $arg['required'] ) && true === $arg['required'] ) {
354 $argument_data['required'] = true;
355 }
356
357 $arguments[] = PromptArgument::fromArray( $argument_data );
358 }
359
360 return $arguments;
361 }
362
363 /**
364 * Convert JSON Schema input_schema to MCP prompt arguments format.
365 *
366 * Converts from WordPress Abilities JSON Schema format:
367 * {
368 * "type": "object",
369 * "properties": {
370 * "topic": {"type": "string", "title": "Topic", "description": "..."},
371 * "tone": {"type": "string", "description": "..."}
372 * },
373 * "required": ["topic"]
374 * }
375 *
376 * To MCP prompt arguments format:
377 * [
378 * {"name": "topic", "title": "Topic", "description": "...", "required": true},
379 * {"name": "tone", "description": "..."}
380 * ]
381 *
382 * Note: `required` is only emitted when true; optional arguments omit the field entirely.
383 *
384 * @param array<string,mixed> $input_schema The JSON Schema from ability.
385 *
386 * @return list<\WP\McpSchema\Server\Prompts\DTO\PromptArgument> Argument DTO list.
387 * @since 0.5.0
388 *
389 */
390 private function convert_input_schema_to_arguments( array $input_schema ): array {
391 $arguments = array();
392
393 // Ensure we have properties to convert.
394 if ( empty( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) {
395 return $arguments;
396 }
397
398 // Get the list of required properties.
399 $required_fields = array();
400 if ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) ) {
401 $required_fields = $input_schema['required'];
402 }
403
404 // Convert each property to an MCP argument.
405 foreach ( $input_schema['properties'] as $property_name => $property_schema ) {
406 if ( ! is_array( $property_schema ) ) {
407 continue;
408 }
409
410 $is_required = in_array( $property_name, $required_fields, true );
411
412 $argument_data = array(
413 'name' => $property_name,
414 );
415
416 // Map JSON Schema title to PromptArgument.title when present.
417 if ( ! empty( $property_schema['title'] ) && is_string( $property_schema['title'] ) ) {
418 $argument_data['title'] = $property_schema['title'];
419 }
420
421 // Map JSON Schema description to PromptArgument.description when present.
422 if ( ! empty( $property_schema['description'] ) && is_string( $property_schema['description'] ) ) {
423 $argument_data['description'] = $property_schema['description'];
424 }
425
426 // Only emit required when true; omit for optional arguments.
427 if ( $is_required ) {
428 $argument_data['required'] = true;
429 }
430
431 $arguments[] = PromptArgument::fromArray( $argument_data );
432 }
433
434 return $arguments;
435 }
436
437 /**
438 * Resolve the MCP prompt name from ability.
439 *
440 * Sanitizes the ability name to MCP-valid format, applies filter, and validates result.
441 *
442 * @since 0.5.0
443 *
444 * @return string|\WP_Error Valid prompt name or error.
445 */
446 private function resolve_prompt_name() {
447 // Sanitize ability name to MCP-valid format.
448 $sanitized_name = McpNameSanitizer::sanitize_name( $this->ability->get_name() );
449
450 if ( is_wp_error( $sanitized_name ) ) {
451 return $sanitized_name;
452 }
453
454 /**
455 * Filters the MCP prompt name derived from an ability.
456 *
457 * @since 0.5.0
458 *
459 * @param string $name The sanitized prompt name.
460 * @param \WP_Ability $ability The source ability instance.
461 */
462 $filtered_name = apply_filters( 'mcp_adapter_prompt_name', $sanitized_name, $this->ability );
463
464 // Validate post-filter (in case filter broke it).
465 if ( ! is_string( $filtered_name ) || ! McpValidator::validate_name( $filtered_name ) ) {
466 return new WP_Error(
467 'mcp_prompt_name_filter_invalid',
468 sprintf(
469 /* translators: %s: invalid prompt name returned by filter */
470 __( 'Filter returned invalid MCP prompt name: %s', 'mcp-adapter' ),
471 is_string( $filtered_name ) ? $filtered_name : gettype( $filtered_name )
472 )
473 );
474 }
475
476 return $filtered_name;
477 }
478
479 /**
480 * Build a clean Prompt DTO and adapter metadata for internal wiring.
481 *
482 * This method returns a protocol-only Prompt DTO and provides the adapter metadata
483 * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution
484 * wiring to protocol surfaces.
485 *
486 * @param \WP_Ability $ability The ability.
487 *
488 * @return array{prompt: \WP\McpSchema\Server\Prompts\DTO\Prompt, adapter_meta: array<string, mixed>}|\WP_Error
489 * @since 0.5.0
490 *
491 */
492 public static function build( \WP_Ability $ability ) {
493 $prompt = new self( $ability );
494 $data = $prompt->build_prompt_data();
495
496 if ( is_wp_error( $data ) ) {
497 return $data;
498 }
499
500 try {
501 $prompt_dto = PromptDto::fromArray( $data['prompt_data'] );
502 } catch ( \Throwable $e ) {
503 return new WP_Error(
504 'mcp_prompt_dto_creation_failed',
505 sprintf(
506 /* translators: %s: error message */
507 __( 'Failed to create Prompt DTO for ability %1$s: %2$s', 'mcp-adapter' ),
508 $ability->get_name(),
509 $e->getMessage()
510 ),
511 array( 'exception' => $e )
512 );
513 }
514
515 // Optional deep validation if enabled.
516 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
517 if ( $mcp_validation_enabled ) {
518 $validation_result = McpPromptValidator::validate_prompt_dto( $prompt_dto );
519 if ( is_wp_error( $validation_result ) ) {
520 return $validation_result;
521 }
522 }
523
524 return array(
525 'prompt' => $prompt_dto,
526 'adapter_meta' => $data['adapter_meta'],
527 );
528 }
529 }
530