PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / lib / mcp-adapter / includes / Domain / Tools / McpToolValidator.php

McpToolValidator.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at lib/mcp-adapter/includes/Domain/Tools/McpToolValidator.php

483 lines 15.5 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 // 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