PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.3
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 2.3.1 All 196 releases
convertkit / vendor / wordpress / mcp-adapter / includes / Domain / Tools / McpToolValidator.php

McpToolValidator.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.3, at vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpToolValidator.php

489 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tool Validator class for validating MCP tools according to the specification.
4 *
5 * @package McpAdapter
6 */
7
8 declare( strict_types=1 );
9
10 namespace WP\MCP\Domain\Tools;
11
12 use WP\MCP\Domain\Utils\McpValidator;
13 use WP\McpSchema\Server\Tools\DTO\Tool as ToolDto;
14 use WP_Error;
15
16 /**
17 * Validates MCP tools against the Model Context Protocol specification.
18 *
19 * Provides minimal, resource-efficient validation to ensure tools conform
20 * to the MCP schema requirements without heavy processing overhead.
21 *
22 * @link https://modelcontextprotocol.io/specification/2025-11-25/server/tools
23 */
24 class McpToolValidator {
25
26 /**
27 * Valid task support values for tool execution.
28 *
29 * @since 0.5.0
30 *
31 * @var array<string>
32 */
33 private static array $valid_task_support_values = array(
34 'forbidden',
35 'optional',
36 'required',
37 );
38
39 /**
40 * Validate the MCP tool data array against the MCP schema.
41 *
42 * @param array $tool_data The tool data to validate.
43 * @param string $context Optional context for error messages.
44 *
45 * @return bool|\WP_Error True if valid, WP_Error if validation fails.
46 */
47 public static function validate_tool_data( array $tool_data, string $context = '' ) {
48 $validation_errors = self::get_validation_errors( $tool_data );
49
50 if ( ! empty( $validation_errors ) ) {
51 $error_message = $context ? "[$context] " : '';
52 $error_message .= sprintf(
53 /* translators: %s: comma-separated list of validation errors */
54 __( 'Tool validation failed: %s', 'mcp-adapter' ),
55 implode( ', ', $validation_errors )
56 );
57 return new WP_Error( 'mcp_tool_validation_failed', esc_html( $error_message ) );
58 }
59
60 return true;
61 }
62
63 /**
64 * Validate an McpTool instance against the MCP schema.
65 *
66 * @param \WP\MCP\Domain\Tools\McpTool $tool The tool instance to validate.
67 * @param string $context Optional context for error messages.
68 *
69 * @return bool|\WP_Error True if valid, WP_Error if validation fails.
70 */
71 public static function validate_tool_instance( McpTool $tool, string $context = '' ) {
72 return self::validate_tool_data( $tool->get_protocol_dto()->toArray(), $context );
73 }
74
75 /**
76 * Validate a Tool DTO against the MCP schema.
77 *
78 * @param \WP\McpSchema\Server\Tools\DTO\Tool $tool The tool DTO to validate.
79 *
80 * @return bool|\WP_Error True if valid, WP_Error otherwise.
81 */
82 public static function validate_tool_dto( ToolDto $tool ) {
83 $errors = array();
84
85 // Validate name (required, 1-128 chars, alphanumeric + _.-).
86 if ( ! McpValidator::validate_name( $tool->getName() ) ) {
87 $errors[] = __( 'Tool name must be 1-128 characters and contain only [A-Za-z0-9_.-]', 'mcp-adapter' );
88 }
89
90 // Validate icons if present.
91 $icons = $tool->getIcons();
92 if ( ! empty( $icons ) ) {
93 // Convert DTO icons to arrays for validation.
94 $icons_array = array_map( static fn( $icon ) => $icon->toArray(), $icons );
95 $icons_result = McpValidator::validate_icons_array( $icons_array );
96 $icons_errors = self::format_icon_validation_errors( $icons_result );
97 $errors = array_merge( $errors, $icons_errors );
98 }
99
100 // Validate annotations if present (tool-specific only).
101 $annotations = $tool->getAnnotations();
102 if ( $annotations ) {
103 $annotations_array = $annotations->toArray();
104 $annotation_errors = self::get_tool_annotation_validation_errors( $annotations_array );
105 $errors = array_merge( $errors, $annotation_errors );
106 }
107
108 // Validate execution if present.
109 $execution = $tool->getExecution();
110 if ( $execution ) {
111 $execution_array = $execution->toArray();
112 $execution_errors = self::get_execution_validation_errors( $execution_array );
113 $errors = array_merge( $errors, $execution_errors );
114 }
115
116 // Validate schemas (inputSchema and outputSchema).
117 $tool_array = $tool->toArray();
118
119 // Validate inputSchema (required field).
120 $input_schema_errors = self::get_schema_validation_errors(
121 $tool_array['inputSchema'] ?? null,
122 'inputSchema'
123 );
124 $errors = array_merge( $errors, $input_schema_errors );
125
126 // Validate outputSchema if present (optional field).
127 if ( isset( $tool_array['outputSchema'] ) ) {
128 $output_schema_errors = self::get_schema_validation_errors(
129 $tool_array['outputSchema'],
130 'outputSchema'
131 );
132 $errors = array_merge( $errors, $output_schema_errors );
133 }
134
135 if ( ! empty( $errors ) ) {
136 return new WP_Error(
137 'mcp_tool_validation_failed',
138 sprintf(
139 /* translators: %s: list of validation errors */
140 __( 'Tool validation failed: %s', 'mcp-adapter' ),
141 implode( '; ', $errors )
142 )
143 );
144 }
145
146 return true;
147 }
148
149 /**
150 * Get validation error details for debugging purposes.
151 * This is the core validation method - all other validation methods use this.
152 *
153 * @param array $tool_data The tool data to validate.
154 *
155 * @return array Array of validation errors, empty if valid.
156 */
157 public static function get_validation_errors( array $tool_data ): array {
158 $errors = array();
159
160 // Check the required field: name.
161 if ( empty( $tool_data['name'] ) || ! is_string( $tool_data['name'] ) || ! McpValidator::validate_name( $tool_data['name'] ) ) {
162 $errors[] = __( 'Tool name is required and must only contain letters, numbers, hyphens (-), underscores (_), and dots (.), and be 128 characters or less', 'mcp-adapter' );
163 }
164
165 // Description is optional per MCP 2025-11-25 spec, but validate if present.
166 if ( isset( $tool_data['description'] ) && ! is_string( $tool_data['description'] ) ) {
167 $errors[] = __( 'Tool description must be a string if provided', 'mcp-adapter' );
168 }
169
170 // Validate inputSchema (required field).
171 $input_schema_errors = self::get_schema_validation_errors( $tool_data['inputSchema'] ?? null, 'inputSchema' );
172 if ( ! empty( $input_schema_errors ) ) {
173 $errors = array_merge( $errors, $input_schema_errors );
174 }
175
176 // Check optional fields if present.
177 if ( isset( $tool_data['title'] ) && ! is_string( $tool_data['title'] ) ) {
178 $errors[] = __( 'Tool title must be a string if provided', 'mcp-adapter' );
179 }
180
181 // Validate outputSchema (optional field).
182 if ( isset( $tool_data['outputSchema'] ) ) {
183 $output_schema_errors = self::get_schema_validation_errors( $tool_data['outputSchema'], 'outputSchema' );
184 if ( ! empty( $output_schema_errors ) ) {
185 $errors = array_merge( $errors, $output_schema_errors );
186 }
187 }
188
189 // Validate icons (optional field, new in 2025-11-25).
190 if ( isset( $tool_data['icons'] ) ) {
191 $icons_errors = self::get_icons_validation_errors( $tool_data['icons'] );
192 if ( ! empty( $icons_errors ) ) {
193 $errors = array_merge( $errors, $icons_errors );
194 }
195 }
196
197 // Validate execution (optional field, new in 2025-11-25).
198 if ( isset( $tool_data['execution'] ) ) {
199 $execution_errors = self::get_execution_validation_errors( $tool_data['execution'] );
200 if ( ! empty( $execution_errors ) ) {
201 $errors = array_merge( $errors, $execution_errors );
202 }
203 }
204
205 // Validate annotations structure if present (tool-specific annotations only).
206 if ( isset( $tool_data['annotations'] ) ) {
207 if ( ! is_array( $tool_data['annotations'] ) ) {
208 $errors[] = __( 'Tool annotations must be an array if provided', 'mcp-adapter' );
209 } else {
210 // Validate tool-specific annotations (readOnlyHint, destructiveHint, etc.).
211 $tool_annotation_errors = self::get_tool_annotation_validation_errors( $tool_data['annotations'] );
212 if ( ! empty( $tool_annotation_errors ) ) {
213 $errors = array_merge( $errors, $tool_annotation_errors );
214 }
215 }
216 }
217
218 // Validate _meta (optional field).
219 if ( isset( $tool_data['_meta'] ) && ! is_array( $tool_data['_meta'] ) ) {
220 $errors[] = __( 'Tool _meta must be an object/array if provided', 'mcp-adapter' );
221 }
222
223 return $errors;
224 }
225
226 /**
227 * Get detailed validation errors for a schema object.
228 *
229 * @param array|mixed $schema The schema to validate.
230 * @param string $field_name The name of the field being validated (for error messages).
231 *
232 * @return array Array of validation errors, empty if valid.
233 */
234 private static function get_schema_validation_errors( $schema, string $field_name ): array {
235 // Normalize stdClass to array for validation and reject scalars/null.
236 if ( $schema instanceof \stdClass ) {
237 $schema = (array) $schema;
238 }
239
240 // Schema must be an array/object - early return for performance.
241 if ( ! is_array( $schema ) ) {
242 return array(
243 sprintf(
244 /* translators: %s: field name (inputSchema or outputSchema) */
245 __( 'Tool %s must be a valid JSON schema object', 'mcp-adapter' ),
246 $field_name
247 ),
248 );
249 }
250
251 $errors = array();
252
253 // MCP Tool inputSchema and outputSchema are currently restricted to a root type of "object".
254 if ( ! isset( $schema['type'] ) ) {
255 $errors[] = sprintf(
256 /* translators: %s: field name */
257 __( 'Tool %s must specify a root type of \'object\'', 'mcp-adapter' ),
258 $field_name
259 );
260 } elseif ( ! is_string( $schema['type'] ) || 'object' !== $schema['type'] ) {
261 $errors[] = sprintf(
262 /* translators: %s: field name */
263 __( 'Tool %s root type must be \'object\'', 'mcp-adapter' ),
264 $field_name
265 );
266 }
267
268 // Normalize stdClass properties (e.g. an empty `{}` emitted by the schema DTO for
269 // parameter-less tools) to an array so the structural checks below treat it as a valid object.
270 if ( isset( $schema['properties'] ) && $schema['properties'] instanceof \stdClass ) {
271 $schema['properties'] = (array) $schema['properties'];
272 }
273
274 // If properties exist, they must be an array/object.
275 if ( isset( $schema['properties'] ) && ! is_array( $schema['properties'] ) ) {
276 $errors[] = sprintf(
277 /* translators: %s: field name */
278 __( 'Tool %s properties must be an object/array', 'mcp-adapter' ),
279 $field_name
280 );
281 }
282
283 // If required exists, it must be an array.
284 if ( isset( $schema['required'] ) && ! is_array( $schema['required'] ) ) {
285 $errors[] = sprintf(
286 /* translators: %s: field name */
287 __( 'Tool %s required field must be an array', 'mcp-adapter' ),
288 $field_name
289 );
290 }
291
292 // If properties are provided, validate their basic structure.
293 if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
294 foreach ( $schema['properties'] as $property_name => $property ) {
295 // Normalize stdClass to array for property validation.
296 if ( $property instanceof \stdClass ) {
297 $property = (array) $property;
298 }
299
300 if ( ! is_array( $property ) ) {
301 $errors[] = sprintf(
302 /* translators: %1$s: field name, %2$s: property name */
303 __( 'Tool %1$s property \'%2$s\' must be an object', 'mcp-adapter' ),
304 $field_name,
305 $property_name
306 );
307 continue;
308 }
309
310 // Each property should have a type (though not strictly required by JSON Schema).
311 if ( ! isset( $property['type'] ) || is_string( $property['type'] ) || is_array( $property['type'] ) ) {
312 continue;
313 }
314
315 // If the type is neither string nor array, it's invalid.
316 $errors[] = sprintf(
317 /* translators: %1$s: field name, %2$s: property name */
318 __( 'Tool %1$s property \'%2$s\' type must be a string or array of strings (union type)', 'mcp-adapter' ),
319 $field_name,
320 $property_name
321 );
322 }
323 }
324
325 // If the required array is provided, validate its structure.
326 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
327 foreach ( $schema['required'] as $required_field ) {
328 if ( ! is_string( $required_field ) ) {
329 $errors[] = sprintf(
330 /* translators: %s: field name */
331 __( 'Tool %s required field names must be strings', 'mcp-adapter' ),
332 $field_name
333 );
334 continue;
335 }
336
337 // Check that required fields exist in properties (if properties are defined).
338 if ( ! isset( $schema['properties'] ) || isset( $schema['properties'][ $required_field ] ) ) {
339 continue;
340 }
341
342 $errors[] = sprintf(
343 /* translators: %1$s: field name, %2$s: required field */
344 __( 'Tool %1$s required field \'%2$s\' does not exist in properties', 'mcp-adapter' ),
345 $field_name,
346 $required_field
347 );
348 }
349 }
350
351 return $errors;
352 }
353
354 /**
355 * Get validation errors for tool icons array.
356 *
357 * @param mixed $icons The icons data to validate.
358 *
359 * @return array Array of validation errors, empty if valid.
360 */
361 private static function get_icons_validation_errors( $icons ): array {
362 if ( ! is_array( $icons ) ) {
363 return array( __( 'Tool icons must be an array if provided', 'mcp-adapter' ) );
364 }
365
366 $icons_result = McpValidator::validate_icons_array( $icons, false );
367
368 return self::format_icon_validation_errors( $icons_result );
369 }
370
371 /**
372 * Format icon validation errors from the validation result.
373 *
374 * @param array{valid: array, errors: array} $icons_result The result from validate_icons_array.
375 *
376 * @return array Array of formatted error messages.
377 */
378 private static function format_icon_validation_errors( array $icons_result ): array {
379 $errors = array();
380
381 if ( ! empty( $icons_result['errors'] ) ) {
382 foreach ( $icons_result['errors'] as $error_group ) {
383 foreach ( $error_group['errors'] as $error ) {
384 $errors[] = sprintf(
385 /* translators: 1: icon index, 2: error message */
386 __( 'Icon at index %1$d: %2$s', 'mcp-adapter' ),
387 $error_group['index'],
388 $error
389 );
390 }
391 }
392 }
393
394 return $errors;
395 }
396
397 /**
398 * Get validation errors for tool execution properties.
399 *
400 * Validates execution-related properties per MCP 2025-11-25 specification:
401 * - taskSupport must be one of: "forbidden", "optional", "required"
402 *
403 * @param mixed $execution The execution data to validate.
404 *
405 * @return array Array of validation errors, empty if valid.
406 */
407 public static function get_execution_validation_errors( $execution ): array {
408 if ( ! is_array( $execution ) ) {
409 return array( __( 'Tool execution must be an object/array if provided', 'mcp-adapter' ) );
410 }
411
412 $errors = array();
413
414 // Validate taskSupport if present.
415 if ( isset( $execution['taskSupport'] ) ) {
416 if ( ! is_string( $execution['taskSupport'] ) ) {
417 $errors[] = __( 'Tool execution taskSupport must be a string', 'mcp-adapter' );
418 } elseif ( ! in_array( $execution['taskSupport'], self::$valid_task_support_values, true ) ) {
419 $errors[] = sprintf(
420 /* translators: %s: comma-separated list of valid values */
421 __( 'Tool execution taskSupport must be one of: %s', 'mcp-adapter' ),
422 implode( ', ', self::$valid_task_support_values )
423 );
424 }
425 }
426
427 return $errors;
428 }
429
430 /**
431 * Get validation errors for tool-specific MCP annotations.
432 *
433 * Validates tool annotation fields per MCP 2025-11-25 specification:
434 * - readOnlyHint, destructiveHint, idempotentHint, openWorldHint must be booleans
435 * - title must be a non-empty string
436 *
437 * Note: Tools use ToolAnnotations which is different from the shared Annotations class.
438 * ToolAnnotations does NOT include audience, lastModified, or priority fields.
439 *
440 * @param array $annotations The annotations to validate.
441 *
442 * @return array Array of validation errors, empty if valid.
443 */
444 public static function get_tool_annotation_validation_errors( array $annotations ): array {
445 $errors = array();
446
447 foreach ( $annotations as $field => $value ) {
448 switch ( $field ) {
449 case 'readOnlyHint':
450 case 'destructiveHint':
451 case 'idempotentHint':
452 case 'openWorldHint':
453 if ( ! is_bool( $value ) ) {
454 $errors[] = sprintf(
455 /* translators: %s: annotation field name */
456 __( 'Tool annotation field %s must be a boolean', 'mcp-adapter' ),
457 $field
458 );
459 }
460 break;
461
462 case 'title':
463 if ( ! is_string( $value ) ) {
464 $errors[] = sprintf(
465 /* translators: %s: annotation field name */
466 __( 'Tool annotation field %s must be a string', 'mcp-adapter' ),
467 $field
468 );
469 break;
470 }
471 if ( empty( trim( $value ) ) ) {
472 $errors[] = sprintf(
473 /* translators: %s: annotation field name */
474 __( 'Tool annotation field %s must be a non-empty string', 'mcp-adapter' ),
475 $field
476 );
477 }
478 break;
479
480 default:
481 // Unknown fields are ignored to allow forward compatibility.
482 break;
483 }
484 }
485
486 return $errors;
487 }
488 }
489