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 / Domain / Prompts / RegisterAbilityAsMcpPrompt.php

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