PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / 2.3.0
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF v2.3.0
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 / Resources / RegisterAbilityAsMcpResource.php

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

480 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 (with validation).
160 $mime_type = $this->get_mcp_meta( 'mimeType', 'string' );
161 if ( null !== $mime_type ) {
162 $mime_type = trim( $mime_type );
163 if ( McpValidator::validate_mime_type( $mime_type ) ) {
164 $resource_data['mimeType'] = $mime_type;
165 }
166 }
167
168 // Optional: size from ability meta (bytes count for UI display).
169 $size = $this->get_mcp_meta( 'size', 'int' );
170 if ( null !== $size && $size > 0 ) {
171 $resource_data['size'] = $size;
172 }
173
174 // Optional: annotations from ability meta (standardized location: mcp.annotations).
175 $annotations = $this->get_mcp_meta( 'annotations', 'array' );
176 if ( null !== $annotations ) {
177 $mcp_annotations = McpAnnotationMapper::map( $annotations, 'resource' );
178 if ( ! empty( $mcp_annotations ) ) {
179 // Validate annotation values per MCP specification.
180 $validation_errors = McpValidator::get_annotation_validation_errors( $mcp_annotations );
181 if ( ! empty( $validation_errors ) ) {
182 // Log the issue but don't fail registration - drop invalid annotations.
183 $this->log_deprecation(
184 self::class . '::get_data',
185 sprintf(
186 /* translators: 1: ability name, 2: validation errors */
187 __( 'Invalid annotations for resource ability "%1$s" will be dropped: %2$s', 'mcp-adapter' ),
188 $this->ability->get_name(),
189 implode( '; ', $validation_errors )
190 ),
191 array( 'validation_errors' => $validation_errors )
192 );
193 } else {
194 $resource_data['annotations'] = $mcp_annotations;
195 }
196 }
197 }
198
199 // Optional: icons from mcp.icons (already in correct location).
200 if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) {
201 $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] );
202 if ( ! empty( $icons_result['valid'] ) ) {
203 $resource_data['icons'] = $icons_result['valid'];
204 }
205 }
206
207 // Build Resource `_meta`:
208 // - Preserve user-provided `_meta` from ability.meta.mcp._meta.
209 // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta.
210 $resource_meta = array();
211 if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) {
212 $resource_meta = $mcp_meta['_meta'];
213 }
214 if ( ! empty( $resource_meta ) ) {
215 $resource_data['_meta'] = $resource_meta;
216 }
217
218 $adapter_meta = array(
219 'ability' => $this->ability->get_name(),
220 );
221
222 return array(
223 'resource_data' => $resource_data,
224 'adapter_meta' => $adapter_meta,
225 );
226 }
227
228 /**
229 * Get the resource URI with validation.
230 *
231 * @return string|\WP_Error URI string or WP_Error if not found or invalid.
232 */
233 private function get_uri() {
234 $uri = $this->get_mcp_meta( 'uri', 'string' );
235
236 if ( null === $uri ) {
237 return new WP_Error(
238 'resource_uri_not_found',
239 sprintf(
240 /* translators: %s: ability name */
241 __( "Resource URI not found in ability meta for '%s'. URI must be provided at 'mcp.uri'.", 'mcp-adapter' ),
242 $this->ability->get_name()
243 )
244 );
245 }
246
247 $uri = trim( $uri );
248
249 // Validate URI format (RFC 3986).
250 if ( ! McpValidator::validate_resource_uri( $uri ) ) {
251 return new WP_Error(
252 'resource_uri_invalid',
253 sprintf(
254 /* translators: 1: ability name, 2: invalid URI */
255 __( "Invalid resource URI '%2\$s' for ability '%1\$s'. URI must be RFC 3986 compliant with a scheme.", 'mcp-adapter' ),
256 $this->ability->get_name(),
257 $uri
258 )
259 );
260 }
261
262 /**
263 * Filters the MCP resource URI derived from an ability.
264 *
265 * @since 0.5.0
266 *
267 * @param string $uri The validated resource URI.
268 * @param \WP_Ability $ability The source ability instance.
269 */
270 $filtered_uri = apply_filters( 'mcp_adapter_resource_uri', $uri, $this->ability );
271
272 // Validate post-filter.
273 if ( ! is_string( $filtered_uri ) || ! McpValidator::validate_resource_uri( $filtered_uri ) ) {
274 return new WP_Error(
275 'mcp_resource_uri_filter_invalid',
276 sprintf(
277 /* translators: %s: invalid URI returned by filter */
278 __( 'Filter returned invalid MCP resource URI: %s', 'mcp-adapter' ),
279 is_string( $filtered_uri ) ? $filtered_uri : gettype( $filtered_uri )
280 )
281 );
282 }
283
284 return $filtered_uri;
285 }
286
287 /**
288 * Get a value from ability meta with standardized lookup.
289 *
290 * Looks in 'mcp' namespace first (preferred), then falls back to top-level (deprecated).
291 * Logs deprecation notice when using top-level location.
292 *
293 * @param string $key The key to look up.
294 * @param string $type Expected type: 'string', 'int', 'array'.
295 * @param mixed $default_value Default value if not found.
296 *
297 * @return mixed The value or default.
298 */
299 private function get_mcp_meta( string $key, string $type = 'string', $default_value = null ) {
300 $ability_meta = $this->ability->get_meta();
301 $mcp_meta = $ability_meta['mcp'] ?? array();
302
303 // Preferred: Check mcp.{key} first.
304 if ( isset( $mcp_meta[ $key ] ) ) {
305 $value = $mcp_meta[ $key ];
306 if ( $this->validate_type( $value, $type ) ) {
307 return $value;
308 }
309 }
310
311 // Deprecated fallback: Check top-level meta.{key}.
312 if ( isset( $ability_meta[ $key ] ) ) {
313 $value = $ability_meta[ $key ];
314 if ( $this->validate_type( $value, $type ) ) {
315 // Log deprecation notice.
316 $this->log_deprecation(
317 __METHOD__,
318 sprintf(
319 /* translators: 1: deprecated meta key, 2: new meta key path */
320 __( 'Ability meta key "%1$s" is deprecated. Use "mcp.%1$s" instead.', 'mcp-adapter' ),
321 $key
322 ),
323 array( 'deprecated_key' => $key )
324 );
325
326 return $value;
327 }
328 }
329
330 return $default_value;
331 }
332
333 /**
334 * Validate a value against expected type.
335 *
336 * @param mixed $value The value to validate.
337 * @param string $type Expected type.
338 *
339 * @return bool True if valid.
340 */
341 private function validate_type( $value, string $type ): bool {
342 switch ( $type ) {
343 case 'string':
344 return is_string( $value ) && '' !== trim( $value );
345 case 'int':
346 return is_int( $value ) && $value >= 0;
347 case 'array':
348 // Array must be non-empty AND have at least one non-null, non-empty value.
349 // This prevents false positives when WordPress adds default empty annotations.
350 if ( ! is_array( $value ) || empty( $value ) ) {
351 return false;
352 }
353 // Check if any value in the array is actually meaningful (non-null, non-empty string).
354 foreach ( $value as $item ) {
355 if ( null !== $item && '' !== $item && array() !== $item ) {
356 return true;
357 }
358 }
359
360 return false;
361 default:
362 return false;
363 }
364 }
365
366 /**
367 * Log a deprecation notice via both WordPress _doing_it_wrong and McpErrorHandler.
368 *
369 * This ensures deprecation notices are visible both as HTTP headers (WordPress REST API)
370 * and in debug.log (McpErrorHandler).
371 *
372 * @param string $method The method name where deprecation occurred.
373 * @param string $message The deprecation message.
374 * @param array $context Additional context for error handler.
375 *
376 * @return void
377 */
378 private function log_deprecation( string $method, string $message, array $context = array() ): void {
379 // WordPress standard deprecation notice (appears as X-WP-DoingItWrong header in REST API).
380 _doing_it_wrong( esc_html( $method ), esc_html( $message ), '0.5.0' );
381
382 // Also log via McpErrorHandler for debug.log visibility.
383 if ( ! $this->error_handler ) {
384 return;
385 }
386
387 $this->error_handler->log(
388 $message,
389 array_merge(
390 array( 'ability' => $this->ability->get_name() ),
391 $context
392 ),
393 'warning'
394 );
395 }
396
397 /**
398 * Resolve the MCP resource name from ability.
399 *
400 * Resource names have no charset restrictions (unlike Tool names).
401 *
402 * @return string The resolved resource name.
403 */
404 private function resolve_resource_name(): string {
405 $name = $this->ability->get_name();
406
407 /**
408 * Filters the MCP resource name derived from an ability.
409 *
410 * Unlike tools, resource names have no charset restrictions.
411 *
412 * @since 0.5.0
413 *
414 * @param string $name The resource name.
415 * @param \WP_Ability $ability The source ability instance.
416 */
417 $filtered_name = apply_filters( 'mcp_adapter_resource_name', $name, $this->ability );
418
419 // Resource names have no charset restrictions, so just ensure it's a non-empty string.
420 if ( is_string( $filtered_name ) && '' !== trim( $filtered_name ) ) {
421 return $filtered_name;
422 }
423
424 // Fall back to original name if filter returns invalid value.
425 return $name;
426 }
427
428 /**
429 * Build a clean Resource DTO and adapter metadata for internal wiring.
430 *
431 * This method returns a protocol-only Resource DTO and provides the adapter metadata
432 * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution
433 * wiring to protocol surfaces.
434 *
435 * @param \WP_Ability $ability The ability.
436 * @param \WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface|null $error_handler Optional error handler.
437 *
438 * @return array{resource: \WP\McpSchema\Server\Resources\DTO\Resource, adapter_meta: array<string, mixed>}|\WP_Error
439 * @since 0.5.0
440 *
441 */
442 public static function build( \WP_Ability $ability, ?McpErrorHandlerInterface $error_handler = null ) {
443 $resource = new self( $ability, $error_handler );
444 $data = $resource->build_resource_data();
445
446 if ( is_wp_error( $data ) ) {
447 return $data;
448 }
449
450 try {
451 $resource_dto = ResourceDto::fromArray( $data['resource_data'] );
452 } catch ( \Throwable $e ) {
453 return new WP_Error(
454 'mcp_resource_dto_creation_failed',
455 sprintf(
456 /* translators: %s: error message */
457 __( 'Failed to create Resource DTO for ability %1$s: %2$s', 'mcp-adapter' ),
458 $ability->get_name(),
459 $e->getMessage()
460 ),
461 array( 'exception' => $e )
462 );
463 }
464
465 // Optional deep validation if enabled.
466 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
467 if ( $mcp_validation_enabled ) {
468 $validation_result = McpResourceValidator::validate_resource_dto( $resource_dto );
469 if ( is_wp_error( $validation_result ) ) {
470 return $validation_result;
471 }
472 }
473
474 return array(
475 'resource' => $resource_dto,
476 'adapter_meta' => $data['adapter_meta'],
477 );
478 }
479 }
480