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 / Resources / RegisterAbilityAsMcpResource.php

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