PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / vendor / wordpress / mcp-adapter / includes / Domain / Tools / McpToolValidator.php
commercebird / vendor / wordpress / mcp-adapter / includes / Domain / Tools Last commit date
McpTool.php 2 months ago McpToolValidator.php 2 months ago RegisterAbilityAsMcpTool.php 2 months ago
McpToolValidator.php
483 lines
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 // If properties exist, they must be an array/object.
269 if ( isset( $schema['properties'] ) && ! is_array( $schema['properties'] ) ) {
270 $errors[] = sprintf(
271 /* translators: %s: field name */
272 __( 'Tool %s properties must be an object/array', 'mcp-adapter' ),
273 $field_name
274 );
275 }
276
277 // If required exists, it must be an array.
278 if ( isset( $schema['required'] ) && ! is_array( $schema['required'] ) ) {
279 $errors[] = sprintf(
280 /* translators: %s: field name */
281 __( 'Tool %s required field must be an array', 'mcp-adapter' ),
282 $field_name
283 );
284 }
285
286 // If properties are provided, validate their basic structure.
287 if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
288 foreach ( $schema['properties'] as $property_name => $property ) {
289 // Normalize stdClass to array for property validation.
290 if ( $property instanceof \stdClass ) {
291 $property = (array) $property;
292 }
293
294 if ( ! is_array( $property ) ) {
295 $errors[] = sprintf(
296 /* translators: %1$s: field name, %2$s: property name */
297 __( 'Tool %1$s property \'%2$s\' must be an object', 'mcp-adapter' ),
298 $field_name,
299 $property_name
300 );
301 continue;
302 }
303
304 // Each property should have a type (though not strictly required by JSON Schema).
305 if ( ! isset( $property['type'] ) || is_string( $property['type'] ) || is_array( $property['type'] ) ) {
306 continue;
307 }
308
309 // If the type is neither string nor array, it's invalid.
310 $errors[] = sprintf(
311 /* translators: %1$s: field name, %2$s: property name */
312 __( 'Tool %1$s property \'%2$s\' type must be a string or array of strings (union type)', 'mcp-adapter' ),
313 $field_name,
314 $property_name
315 );
316 }
317 }
318
319 // If the required array is provided, validate its structure.
320 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
321 foreach ( $schema['required'] as $required_field ) {
322 if ( ! is_string( $required_field ) ) {
323 $errors[] = sprintf(
324 /* translators: %s: field name */
325 __( 'Tool %s required field names must be strings', 'mcp-adapter' ),
326 $field_name
327 );
328 continue;
329 }
330
331 // Check that required fields exist in properties (if properties are defined).
332 if ( ! isset( $schema['properties'] ) || isset( $schema['properties'][ $required_field ] ) ) {
333 continue;
334 }
335
336 $errors[] = sprintf(
337 /* translators: %1$s: field name, %2$s: required field */
338 __( 'Tool %1$s required field \'%2$s\' does not exist in properties', 'mcp-adapter' ),
339 $field_name,
340 $required_field
341 );
342 }
343 }
344
345 return $errors;
346 }
347
348 /**
349 * Get validation errors for tool icons array.
350 *
351 * @param mixed $icons The icons data to validate.
352 *
353 * @return array Array of validation errors, empty if valid.
354 */
355 private static function get_icons_validation_errors( $icons ): array {
356 if ( ! is_array( $icons ) ) {
357 return array( __( 'Tool icons must be an array if provided', 'mcp-adapter' ) );
358 }
359
360 $icons_result = McpValidator::validate_icons_array( $icons, false );
361
362 return self::format_icon_validation_errors( $icons_result );
363 }
364
365 /**
366 * Format icon validation errors from the validation result.
367 *
368 * @param array{valid: array, errors: array} $icons_result The result from validate_icons_array.
369 *
370 * @return array Array of formatted error messages.
371 */
372 private static function format_icon_validation_errors( array $icons_result ): array {
373 $errors = array();
374
375 if ( ! empty( $icons_result['errors'] ) ) {
376 foreach ( $icons_result['errors'] as $error_group ) {
377 foreach ( $error_group['errors'] as $error ) {
378 $errors[] = sprintf(
379 /* translators: 1: icon index, 2: error message */
380 __( 'Icon at index %1$d: %2$s', 'mcp-adapter' ),
381 $error_group['index'],
382 $error
383 );
384 }
385 }
386 }
387
388 return $errors;
389 }
390
391 /**
392 * Get validation errors for tool execution properties.
393 *
394 * Validates execution-related properties per MCP 2025-11-25 specification:
395 * - taskSupport must be one of: "forbidden", "optional", "required"
396 *
397 * @param mixed $execution The execution data to validate.
398 *
399 * @return array Array of validation errors, empty if valid.
400 */
401 public static function get_execution_validation_errors( $execution ): array {
402 if ( ! is_array( $execution ) ) {
403 return array( __( 'Tool execution must be an object/array if provided', 'mcp-adapter' ) );
404 }
405
406 $errors = array();
407
408 // Validate taskSupport if present.
409 if ( isset( $execution['taskSupport'] ) ) {
410 if ( ! is_string( $execution['taskSupport'] ) ) {
411 $errors[] = __( 'Tool execution taskSupport must be a string', 'mcp-adapter' );
412 } elseif ( ! in_array( $execution['taskSupport'], self::$valid_task_support_values, true ) ) {
413 $errors[] = sprintf(
414 /* translators: %s: comma-separated list of valid values */
415 __( 'Tool execution taskSupport must be one of: %s', 'mcp-adapter' ),
416 implode( ', ', self::$valid_task_support_values )
417 );
418 }
419 }
420
421 return $errors;
422 }
423
424 /**
425 * Get validation errors for tool-specific MCP annotations.
426 *
427 * Validates tool annotation fields per MCP 2025-11-25 specification:
428 * - readOnlyHint, destructiveHint, idempotentHint, openWorldHint must be booleans
429 * - title must be a non-empty string
430 *
431 * Note: Tools use ToolAnnotations which is different from the shared Annotations class.
432 * ToolAnnotations does NOT include audience, lastModified, or priority fields.
433 *
434 * @param array $annotations The annotations to validate.
435 *
436 * @return array Array of validation errors, empty if valid.
437 */
438 public static function get_tool_annotation_validation_errors( array $annotations ): array {
439 $errors = array();
440
441 foreach ( $annotations as $field => $value ) {
442 switch ( $field ) {
443 case 'readOnlyHint':
444 case 'destructiveHint':
445 case 'idempotentHint':
446 case 'openWorldHint':
447 if ( ! is_bool( $value ) ) {
448 $errors[] = sprintf(
449 /* translators: %s: annotation field name */
450 __( 'Tool annotation field %s must be a boolean', 'mcp-adapter' ),
451 $field
452 );
453 }
454 break;
455
456 case 'title':
457 if ( ! is_string( $value ) ) {
458 $errors[] = sprintf(
459 /* translators: %s: annotation field name */
460 __( 'Tool annotation field %s must be a string', 'mcp-adapter' ),
461 $field
462 );
463 break;
464 }
465 if ( empty( trim( $value ) ) ) {
466 $errors[] = sprintf(
467 /* translators: %s: annotation field name */
468 __( 'Tool annotation field %s must be a non-empty string', 'mcp-adapter' ),
469 $field
470 );
471 }
472 break;
473
474 default:
475 // Unknown fields are ignored to allow forward compatibility.
476 break;
477 }
478 }
479
480 return $errors;
481 }
482 }
483