PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.2
Elementor Website Builder – more than just a page builder v4.3.2
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 / Resources / RegisterAbilityAsMcpResource.php

RegisterAbilityAsMcpResource.php in Elementor Website Builder – more than just a page builder 4.3.2, at vendor/wordpress/mcp-adapter/includes/Domain/Resources/RegisterAbilityAsMcpResource.php

479 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * RegisterAbilityAsMcpResource class for converting WordPress abilities to MCP resources.
5 *
6 * @package McpAdapter
7 */
8
9 declare( strict_types=1 );
10
11 namespace WP\MCP\Domain\Resources;
12
13 use WP\MCP\Domain\Utils\McpAnnotationMapper;
14 use WP\MCP\Domain\Utils\McpValidator;
15 use WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface;
16 use WP\McpSchema\Server\Resources\DTO\Resource as ResourceDto;
17 use WP_Error;
18
19 /**
20 * Converts WordPress abilities to MCP Resource metadata.
21 *
22 * This class builds Resource DTOs for resources/list responses.
23 * It extracts metadata only (uri, name, title, description, mimeType, size, icons, annotations).
24 * Resource content (text/blob) is resolved separately at resources/read time.
25 *
26 * All MCP-specific ability meta should be under 'mcp' key:
27 *
28 * Required ability meta:
29 * - 'mcp.uri' (string): The resource URI (RFC 3986 format)
30 *
31 * Optional ability meta:
32 * - 'mcp.mimeType' (string): MIME type of the resource content
33 * - 'mcp.size' (int): Size of resource content in bytes
34 * - 'mcp.annotations' (array): MCP annotations (audience, priority, lastModified)
35 * - 'mcp.icons' (array): Array of icon objects for UI display
36 * - 'mcp._meta' (array): User-provided metadata to pass through
37 *
38 * Note: Top-level meta keys 'uri', 'mimeType', 'annotations' are deprecated as of 0.5.0.
39 * They still work for backward compatibility but will trigger a `_doing_it_wrong` notice.
40 * Use 'mcp.uri', 'mcp.mimeType', 'mcp.annotations' instead.
41 *
42 * @since 0.5.0
43 */
44 class RegisterAbilityAsMcpResource {
45
46 /**
47 * The WordPress ability instance.
48 *
49 * @var \WP_Ability
50 */
51 private \WP_Ability $ability;
52
53 /**
54 * Optional error handler for logging deprecation notices.
55 *
56 * @var \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null
57 */
58 private ?McpErrorHandlerInterface $error_handler;
59
60 /**
61 * Constructor.
62 *
63 * @param \WP_Ability $ability The ability.
64 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler.
65 */
66 private function __construct( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) {
67 $this->ability = $ability;
68 $this->error_handler = $error_handler;
69 }
70
71 /**
72 * Make a new instance of the class.
73 *
74 * @param \WP_Ability $ability The ability.
75 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler for logging.
76 *
77 * @return \WP\McpSchema\Server\Resources\DTO\Resource|\WP_Error Returns Resource DTO or WP_Error if validation fails.
78 */
79 public static function make( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) {
80 $resource = new self( $ability, $error_handler );
81
82 return $resource->get_resource();
83 }
84
85 /**
86 * Get the MCP resource instance.
87 *
88 * Resource schema validity is enforced by the php-mcp-schema DTO constructor.
89 *
90 * @return \WP\McpSchema\Server\Resources\DTO\Resource|\WP_Error Returns the Resource DTO or WP_Error if validation fails.
91 */
92 private function get_resource() {
93 $data = $this->get_data();
94 if ( is_wp_error( $data ) ) {
95 return $data;
96 }
97
98 try {
99 return ResourceDto::fromArray( $data );
100 } catch ( \Throwable $e ) {
101 return new WP_Error(
102 'mcp_resource_schema_invalid',
103 $e->getMessage()
104 );
105 }
106 }
107
108 /**
109 * Get the MCP resource data array.
110 *
111 * Builds metadata-only Resource data. Content (text/blob) is NOT included here;
112 * content is resolved at resources/read time by ResourcesHandler.
113 *
114 * @return array<string,mixed>|\WP_Error Resource data array or WP_Error if validation fails.
115 */
116 private function get_data() {
117 $built = $this->build_resource_data();
118 if ( is_wp_error( $built ) ) {
119 return $built;
120 }
121
122 return $built['resource_data'];
123 }
124
125 /**
126 * Build Resource DTO data and adapter metadata.
127 *
128 * @return array{resource_data: array<string, mixed>, adapter_meta: array<string, mixed>}|\WP_Error
129 * @since 0.5.0
130 *
131 */
132 private function build_resource_data() {
133 $uri = $this->get_uri();
134 if ( is_wp_error( $uri ) ) {
135 return $uri;
136 }
137
138 $ability_meta = $this->ability->get_meta();
139 $mcp_meta = $ability_meta['mcp'] ?? array();
140
141 // Required fields.
142 $resource_data = array(
143 'name' => $this->resolve_resource_name(),
144 'uri' => $uri,
145 );
146
147 // Optional: title from ability label (human-readable display name).
148 $label = trim( $this->ability->get_label() );
149 if ( '' !== $label ) {
150 $resource_data['title'] = $label;
151 }
152
153 // Optional: description.
154 $description = trim( $this->ability->get_description() );
155 if ( '' !== $description ) {
156 $resource_data['description'] = $description;
157 }
158
159 // Optional: mimeType from ability meta. MCP treats it as an opaque string, so the
160 // value is emitted unaltered once surrounding whitespace is trimmed off; only a
161 // non-empty result is required.
162 $mime_type = $this->get_mcp_meta( 'mimeType', 'string' );
163 if ( null !== $mime_type ) {
164 $mime_type = trim( $mime_type );
165 if ( '' !== $mime_type ) {
166 $resource_data['mimeType'] = $mime_type;
167 }
168 }
169
170 // Optional: size from ability meta (bytes count for UI display).
171 $size = $this->get_mcp_meta( 'size', 'int' );
172 if ( null !== $size && $size > 0 ) {
173 $resource_data['size'] = $size;
174 }
175
176 // Optional: annotations from ability meta (standardized location: mcp.annotations).
177 $annotations = $this->get_mcp_meta( 'annotations', 'array' );
178 if ( null !== $annotations ) {
179 $mcp_annotations = McpAnnotationMapper::map( $annotations, 'resource' );
180 if ( ! empty( $mcp_annotations ) ) {
181 // Validate annotation values per MCP specification.
182 $validation_errors = McpValidator::get_annotation_validation_errors( $mcp_annotations );
183 if ( ! empty( $validation_errors ) ) {
184 // Log the issue but don't fail registration - drop invalid annotations.
185 $this->log_deprecation(
186 self::class . '::get_data',
187 sprintf(
188 /* translators: 1: ability name, 2: validation errors */
189 __( 'Invalid annotations for resource ability "%1$s" will be dropped: %2$s', 'mcp-adapter' ),
190 $this->ability->get_name(),
191 implode( '; ', $validation_errors )
192 ),
193 array( 'validation_errors' => $validation_errors )
194 );
195 } else {
196 $resource_data['annotations'] = $mcp_annotations;
197 }
198 }
199 }
200
201 // Optional: icons from mcp.icons (already in correct location).
202 if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) {
203 $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] );
204 if ( ! empty( $icons_result['valid'] ) ) {
205 $resource_data['icons'] = $icons_result['valid'];
206 }
207 }
208
209 // Build Resource `_meta`:
210 // - Preserve user-provided `_meta` from ability.meta.mcp._meta.
211 // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta.
212 $resource_meta = McpValidator::normalize_meta( $mcp_meta['_meta'] ?? null );
213 if ( null !== $resource_meta ) {
214 $resource_data['_meta'] = $resource_meta;
215 }
216
217 $adapter_meta = array(
218 'ability' => $this->ability->get_name(),
219 );
220
221 return array(
222 'resource_data' => $resource_data,
223 'adapter_meta' => $adapter_meta,
224 );
225 }
226
227 /**
228 * Get the resource URI with validation.
229 *
230 * @return string|\WP_Error URI string or WP_Error if not found or invalid.
231 */
232 private function get_uri() {
233 $uri = $this->get_mcp_meta( 'uri', 'string' );
234
235 if ( null === $uri ) {
236 return new WP_Error(
237 'resource_uri_not_found',
238 sprintf(
239 /* translators: %s: ability name */
240 __( "Resource URI not found in ability meta for '%s'. URI must be provided at 'mcp.uri'.", 'mcp-adapter' ),
241 $this->ability->get_name()
242 )
243 );
244 }
245
246 $uri = trim( $uri );
247
248 // Validate URI format (RFC 3986).
249 if ( ! McpValidator::validate_resource_uri( $uri ) ) {
250 return new WP_Error(
251 'resource_uri_invalid',
252 sprintf(
253 /* translators: 1: ability name, 2: invalid URI */
254 __( "Invalid resource URI '%2\$s' for ability '%1\$s'. URI must be RFC 3986 compliant with a scheme.", 'mcp-adapter' ),
255 $this->ability->get_name(),
256 $uri
257 )
258 );
259 }
260
261 /**
262 * Filters the MCP resource URI derived from an ability.
263 *
264 * @since 0.5.0
265 *
266 * @param string $uri The validated resource URI.
267 * @param \WP_Ability $ability The source ability instance.
268 */
269 $filtered_uri = apply_filters( 'mcp_adapter_resource_uri', $uri, $this->ability );
270
271 // Validate post-filter.
272 if ( ! is_string( $filtered_uri ) || ! McpValidator::validate_resource_uri( $filtered_uri ) ) {
273 return new WP_Error(
274 'mcp_resource_uri_filter_invalid',
275 sprintf(
276 /* translators: %s: invalid URI returned by filter */
277 __( 'Filter returned invalid MCP resource URI: %s', 'mcp-adapter' ),
278 is_string( $filtered_uri ) ? $filtered_uri : gettype( $filtered_uri )
279 )
280 );
281 }
282
283 return $filtered_uri;
284 }
285
286 /**
287 * Get a value from ability meta with standardized lookup.
288 *
289 * Looks in 'mcp' namespace first (preferred), then falls back to top-level (deprecated).
290 * Logs deprecation notice when using top-level location.
291 *
292 * @param string $key The key to look up.
293 * @param string $type Expected type: 'string', 'int', 'array'.
294 * @param mixed $default_value Default value if not found.
295 *
296 * @return mixed The value or default.
297 */
298 private function get_mcp_meta( string $key, string $type = 'string', $default_value = null ) {
299 $ability_meta = $this->ability->get_meta();
300 $mcp_meta = $ability_meta['mcp'] ?? array();
301
302 // Preferred: Check mcp.{key} first.
303 if ( isset( $mcp_meta[ $key ] ) ) {
304 $value = $mcp_meta[ $key ];
305 if ( $this->validate_type( $value, $type ) ) {
306 return $value;
307 }
308 }
309
310 // Deprecated fallback: Check top-level meta.{key}.
311 if ( isset( $ability_meta[ $key ] ) ) {
312 $value = $ability_meta[ $key ];
313 if ( $this->validate_type( $value, $type ) ) {
314 // Log deprecation notice.
315 $this->log_deprecation(
316 __METHOD__,
317 sprintf(
318 /* translators: 1: deprecated meta key, 2: new meta key path */
319 __( 'Ability meta key "%1$s" is deprecated. Use "mcp.%1$s" instead.', 'mcp-adapter' ),
320 $key
321 ),
322 array( 'deprecated_key' => $key )
323 );
324
325 return $value;
326 }
327 }
328
329 return $default_value;
330 }
331
332 /**
333 * Validate a value against expected type.
334 *
335 * @param mixed $value The value to validate.
336 * @param string $type Expected type.
337 *
338 * @return bool True if valid.
339 */
340 private function validate_type( $value, string $type ): bool {
341 switch ( $type ) {
342 case 'string':
343 return is_string( $value ) && '' !== trim( $value );
344 case 'int':
345 return is_int( $value ) && $value >= 0;
346 case 'array':
347 // Array must be non-empty AND have at least one non-null, non-empty value.
348 // This prevents false positives when WordPress adds default empty annotations.
349 if ( ! is_array( $value ) || empty( $value ) ) {
350 return false;
351 }
352 // Check if any value in the array is actually meaningful (non-null, non-empty string).
353 foreach ( $value as $item ) {
354 if ( null !== $item && '' !== $item && array() !== $item ) {
355 return true;
356 }
357 }
358
359 return false;
360 default:
361 return false;
362 }
363 }
364
365 /**
366 * Log a deprecation notice via both WordPress _doing_it_wrong and McpErrorHandler.
367 *
368 * This ensures deprecation notices are visible both as HTTP headers (WordPress REST API)
369 * and in debug.log (McpErrorHandler).
370 *
371 * @param string $method The method name where deprecation occurred.
372 * @param string $message The deprecation message.
373 * @param array $context Additional context for error handler.
374 *
375 * @return void
376 */
377 private function log_deprecation( string $method, string $message, array $context = array() ): void {
378 // WordPress standard deprecation notice (appears as X-WP-DoingItWrong header in REST API).
379 _doing_it_wrong( esc_html( $method ), esc_html( $message ), '0.5.0' );
380
381 // Also log via McpErrorHandler for debug.log visibility.
382 if ( ! $this->error_handler ) {
383 return;
384 }
385
386 $this->error_handler->log(
387 $message,
388 array_merge(
389 array( 'ability' => $this->ability->get_name() ),
390 $context
391 ),
392 'warning'
393 );
394 }
395
396 /**
397 * Resolve the MCP resource name from ability.
398 *
399 * Resource names have no charset restrictions (unlike Tool names).
400 *
401 * @return string The resolved resource name.
402 */
403 private function resolve_resource_name(): string {
404 $name = $this->ability->get_name();
405
406 /**
407 * Filters the MCP resource name derived from an ability.
408 *
409 * Unlike tools, resource names have no charset restrictions.
410 *
411 * @since 0.5.0
412 *
413 * @param string $name The resource name.
414 * @param \WP_Ability $ability The source ability instance.
415 */
416 $filtered_name = apply_filters( 'mcp_adapter_resource_name', $name, $this->ability );
417
418 // Resource names have no charset restrictions, so just ensure it's a non-empty string.
419 if ( is_string( $filtered_name ) && '' !== trim( $filtered_name ) ) {
420 return $filtered_name;
421 }
422
423 // Fall back to original name if filter returns invalid value.
424 return $name;
425 }
426
427 /**
428 * Build a clean Resource DTO and adapter metadata for internal wiring.
429 *
430 * This method returns a protocol-only Resource DTO and provides the adapter metadata
431 * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution
432 * wiring to protocol surfaces.
433 *
434 * @param \WP_Ability $ability The ability.
435 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler.
436 *
437 * @return array{resource: \WP\McpSchema\Server\Resources\DTO\Resource, adapter_meta: array<string, mixed>}|\WP_Error
438 * @since 0.5.0
439 *
440 */
441 public static function build( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) {
442 $resource = new self( $ability, $error_handler );
443 $data = $resource->build_resource_data();
444
445 if ( is_wp_error( $data ) ) {
446 return $data;
447 }
448
449 try {
450 $resource_dto = ResourceDto::fromArray( $data['resource_data'] );
451 } catch ( \Throwable $e ) {
452 return new WP_Error(
453 'mcp_resource_dto_creation_failed',
454 sprintf(
455 /* translators: %s: error message */
456 __( 'Failed to create Resource DTO for ability %1$s: %2$s', 'mcp-adapter' ),
457 $ability->get_name(),
458 $e->getMessage()
459 ),
460 array( 'exception' => $e )
461 );
462 }
463
464 // Optional deep validation if enabled.
465 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
466 if ( $mcp_validation_enabled ) {
467 $validation_result = McpResourceValidator::validate_resource_dto( $resource_dto );
468 if ( is_wp_error( $validation_result ) ) {
469 return $validation_result;
470 }
471 }
472
473 return array(
474 'resource' => $resource_dto,
475 'adapter_meta' => $data['adapter_meta'],
476 );
477 }
478 }
479