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 / Utils / McpValidator.php

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

514 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * MCP Validator utility class for validating MCP component data.
5 *
6 * @package McpAdapter
7 */
8
9 declare( strict_types=1 );
10
11 namespace WP\MCP\Domain\Utils;
12
13 use DateTime;
14
15 /**
16 * Utility class for validating MCP component data according to MCP specification.
17 *
18 * Provides shared validation implementations used across multiple MCP component
19 * validators and registration classes. Each method focuses on a specific validation concern.
20 */
21 class McpValidator {
22
23 /**
24 * URI scheme grammar per RFC 3986 §3.1 (unanchored regex fragment).
25 *
26 * Shared by URI validation and scheme folding so the two can never drift
27 * apart on what counts as a scheme.
28 *
29 * @since 0.6.0
30 *
31 * @var string
32 */
33 private const URI_SCHEME_PATTERN = '[a-zA-Z][a-zA-Z0-9+.-]*';
34
35 /**
36 * Validate an MCP component name.
37 *
38 * Validates that a name follows MCP naming conventions per MCP 2025-11-25 spec:
39 * - Must not be empty
40 * - Must not exceed the maximum length
41 * - Must only contain letters, numbers, hyphens (-), underscores (_), and dots (.)
42 *
43 * @param string $name The name to validate.
44 * @param int $max_length Maximum allowed length. Default is 128 per MCP spec.
45 *
46 * @return bool True if valid, false otherwise.
47 * @since 0.5.0
48 *
49 */
50 public static function validate_name( string $name, int $max_length = 128 ): bool {
51 // Names should not be empty (but allow "0" since it matches the regex).
52 if ( '' === $name ) {
53 return false;
54 }
55
56 // Check length constraints.
57 if ( strlen( $name ) > $max_length ) {
58 return false;
59 }
60
61 // Only allow letters, numbers, hyphens, underscores, and dots per MCP spec.
62 return (bool) preg_match( '/^[a-zA-Z0-9_.-]+$/', $name );
63 }
64
65 /**
66 * Validate base64 content.
67 *
68 * Checks if a string is valid base64-encoded content.
69 *
70 * @param string $content The content to validate as base64.
71 *
72 * @return bool True if valid base64, false otherwise.
73 */
74 public static function validate_base64( string $content ): bool {
75 // Base64 content should not be empty.
76 if ( empty( $content ) ) {
77 return false;
78 }
79
80 // Reject whitespace-only strings (they decode to empty string but aren't valid base64 content).
81 if ( trim( $content ) === '' ) {
82 return false;
83 }
84
85 // Check if it's valid base64 encoding.
86 return base64_decode( $content, true ) !== false; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
87 }
88
89 /**
90 * Validate an array of icons.
91 *
92 * Returns valid icons and logs warnings for invalid ones.
93 * Invalid icons are filtered out (graceful degradation).
94 *
95 * @param array $icons Array of icon data.
96 * @param bool $log_warnings Whether to log warnings for invalid icons. Default true.
97 *
98 * @return array{valid: array, errors: array} Array with 'valid' icons and 'errors' details.
99 * @since 0.5.0
100 *
101 */
102 public static function validate_icons_array( array $icons, bool $log_warnings = true ): array {
103 $valid_icons = array();
104 $all_errors = array();
105
106 foreach ( $icons as $index => $icon ) {
107 if ( ! is_array( $icon ) ) {
108 $all_errors[] = array(
109 'index' => $index,
110 'errors' => array( __( 'Icon must be an array', 'mcp-adapter' ) ),
111 );
112 continue;
113 }
114
115 $errors = self::get_icon_validation_errors( $icon );
116
117 if ( empty( $errors ) ) {
118 $valid_icons[] = $icon;
119 } else {
120 $all_errors[] = array(
121 'index' => $index,
122 'errors' => $errors,
123 );
124
125 if ( $log_warnings ) {
126 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
127 error_log(
128 sprintf(
129 'MCP Adapter: Invalid icon at index %d skipped: %s',
130 $index,
131 implode( '; ', $errors )
132 )
133 );
134 }
135 }
136 }
137
138 return array(
139 'valid' => $valid_icons,
140 'errors' => $all_errors,
141 );
142 }
143
144 /**
145 * Get validation errors for an MCP icon object.
146 *
147 * Validates icon fields per MCP 2025-11-25 specification:
148 * - src (required): Valid URL or data: URI
149 * - mimeType (optional): String emitted as declared
150 * - sizes (optional): Array of size strings in WxH format or "any"
151 * - theme (optional): "light" or "dark"
152 *
153 * @param array $icon The icon data to validate.
154 *
155 * @return array Array of validation errors, empty if valid.
156 * @since 0.5.0
157 *
158 */
159 public static function get_icon_validation_errors( array $icon ): array {
160 $errors = array();
161
162 // src is required.
163 if ( ! isset( $icon['src'] ) ) {
164 $errors[] = __( 'Icon must have a src field', 'mcp-adapter' );
165 } elseif ( ! is_string( $icon['src'] ) ) {
166 $errors[] = __( 'Icon src must be a string', 'mcp-adapter' );
167 } elseif ( ! self::validate_icon_src( $icon['src'] ) ) {
168 $errors[] = __( 'Icon src must be a valid URL (http/https) or data: URI', 'mcp-adapter' );
169 }
170
171 // mimeType is optional. Only its type is checked.
172 if ( isset( $icon['mimeType'] ) && ! is_string( $icon['mimeType'] ) ) {
173 $errors[] = __( 'Icon mimeType must be a string', 'mcp-adapter' );
174 }
175
176 // sizes is optional but must be valid if present.
177 if ( isset( $icon['sizes'] ) ) {
178 if ( ! is_array( $icon['sizes'] ) ) {
179 $errors[] = __( 'Icon sizes must be an array', 'mcp-adapter' );
180 } else {
181 foreach ( $icon['sizes'] as $index => $size ) {
182 if ( ! is_string( $size ) ) {
183 $errors[] = sprintf(
184 /* translators: %d: array index */
185 __( 'Icon size at index %d must be a string', 'mcp-adapter' ),
186 $index
187 );
188 } elseif ( ! self::validate_icon_size( $size ) ) {
189 $errors[] = sprintf(
190 /* translators: 1: size value, 2: array index */
191 __( 'Icon size "%1$s" at index %2$d must be in WxH format (e.g., "48x48") or "any"', 'mcp-adapter' ),
192 $size,
193 $index
194 );
195 }
196 }
197 }
198 }
199
200 // theme is optional but must be valid if present.
201 if ( isset( $icon['theme'] ) ) {
202 if ( ! is_string( $icon['theme'] ) ) {
203 $errors[] = __( 'Icon theme must be a string', 'mcp-adapter' );
204 } elseif ( ! self::validate_icon_theme( $icon['theme'] ) ) {
205 $errors[] = __( 'Icon theme must be "light" or "dark"', 'mcp-adapter' );
206 }
207 }
208
209 return $errors;
210 }
211
212 /**
213 * Validate an icon source (src) value.
214 *
215 * Icon src must be a valid URL (http/https) or a data: URI with base64-encoded image data.
216 *
217 * @param string $src The icon source to validate.
218 *
219 * @return bool True if valid, false otherwise.
220 * @since 0.5.0
221 *
222 */
223 public static function validate_icon_src( string $src ): bool {
224 $src = trim( $src );
225
226 if ( empty( $src ) ) {
227 return false;
228 }
229
230 // Check for data: URI.
231 if ( str_starts_with( $src, 'data:' ) ) {
232 // data:[<mediatype>][;base64],<data>
233 // Simplified validation: must have data: prefix and contain comma.
234 return str_contains( $src, ',' );
235 }
236
237 // Check for http/https URL.
238 if ( str_starts_with( $src, 'http://' ) || str_starts_with( $src, 'https://' ) ) {
239 return filter_var( $src, FILTER_VALIDATE_URL ) !== false;
240 }
241
242 return false;
243 }
244
245 /**
246 * Validate an icon size string.
247 *
248 * Icon sizes must be in WxH format (e.g., "48x48", "96x96") or "any" for scalable formats.
249 * Both width and height must be positive integers (no zero dimensions, no leading zeros).
250 *
251 * @param string $size The size string to validate.
252 *
253 * @return bool True if valid, false otherwise.
254 * @since 0.5.0
255 *
256 */
257 public static function validate_icon_size( string $size ): bool {
258 $size = trim( $size );
259
260 if ( empty( $size ) ) {
261 return false;
262 }
263
264 // "any" is valid for scalable formats like SVG.
265 if ( 'any' === strtolower( $size ) ) {
266 return true;
267 }
268
269 // Must match WxH format with positive integers (no zero dimensions, no leading zeros).
270 // [1-9]\d* matches: 1, 2, ..., 9, 10, 11, ..., 99, 100, etc.
271 return (bool) preg_match( '/^[1-9]\d*x[1-9]\d*$/', $size );
272 }
273
274 /**
275 * Validate an icon theme value.
276 *
277 * Valid themes are "light" or "dark".
278 *
279 * @param string $theme The theme to validate.
280 *
281 * @return bool True if valid, false otherwise.
282 * @since 0.5.0
283 *
284 */
285 public static function validate_icon_theme( string $theme ): bool {
286 return in_array( strtolower( trim( $theme ) ), array( 'light', 'dark' ), true );
287 }
288
289 /**
290 * Get validation errors for shared MCP annotations.
291 *
292 * Validates shared annotation fields per MCP 2025-11-25 specification:
293 * - audience must be an array of valid Role values ("user", "assistant")
294 * - lastModified must be a valid ISO 8601 formatted string
295 * - priority must be a number between 0.0 and 1.0
296 *
297 * Only validates known shared annotation fields. Unknown fields are ignored.
298 * Used by resources and content types (text, image, audio).
299 *
300 * Note: Tools use ToolAnnotations which is a separate type validated by McpToolValidator.
301 *
302 * @param array $annotations The annotations to validate.
303 *
304 * @return array Array of validation errors, empty if valid.
305 */
306 public static function get_annotation_validation_errors( array $annotations ): array {
307 $errors = array();
308
309 foreach ( $annotations as $field => $value ) {
310 switch ( $field ) {
311 case 'audience':
312 if ( ! is_array( $value ) ) {
313 $errors[] = __( 'Annotation field audience must be an array', 'mcp-adapter' );
314 break;
315 }
316 if ( ! self::validate_roles_array( $value ) ) {
317 $errors[] = __( 'Annotation field audience must contain only valid roles ("user" or "assistant")', 'mcp-adapter' );
318 }
319 break;
320
321 case 'lastModified':
322 if ( ! is_string( $value ) || empty( trim( $value ) ) ) {
323 $errors[] = __( 'Annotation field lastModified must be a non-empty string', 'mcp-adapter' );
324 break;
325 }
326 if ( ! self::validate_iso8601_timestamp( trim( $value ) ) ) {
327 $errors[] = __( 'Annotation field lastModified must be a valid ISO 8601 timestamp', 'mcp-adapter' );
328 }
329 break;
330
331 case 'priority':
332 if ( ! is_numeric( $value ) ) {
333 $errors[] = __( 'Annotation field priority must be a number', 'mcp-adapter' );
334 break;
335 }
336 if ( ! self::validate_priority( $value ) ) {
337 $errors[] = __( 'Annotation field priority must be between 0.0 and 1.0', 'mcp-adapter' );
338 }
339 break;
340
341 default:
342 // Unknown fields are ignored to allow forward compatibility.
343 break;
344 }
345 }
346
347 return $errors;
348 }
349
350 /**
351 * Validate an array of roles according to MCP specification.
352 *
353 * All roles must be strings and must be either "user" or "assistant".
354 *
355 * @param array $roles The roles array to validate.
356 *
357 * @return bool True if all roles are valid, false otherwise.
358 */
359 public static function validate_roles_array( array $roles ): bool {
360 foreach ( $roles as $role ) {
361 if ( ! is_string( $role ) || ! self::validate_role( $role ) ) {
362 return false;
363 }
364 }
365
366 return true;
367 }
368
369 /**
370 * Validate a role value according to MCP specification.
371 *
372 * Valid roles are "user" or "assistant".
373 *
374 * @param string $role The role to validate.
375 *
376 * @return bool True if valid, false otherwise.
377 */
378 public static function validate_role( string $role ): bool {
379 return in_array( $role, array( 'user', 'assistant' ), true );
380 }
381
382 /**
383 * Validate ISO 8601 timestamp format.
384 *
385 * Checks if a string is a valid ISO 8601 timestamp by attempting to parse
386 * it using multiple ISO 8601 format variations.
387 *
388 * @param string $timestamp The timestamp to validate.
389 *
390 * @return bool True if valid ISO 8601 timestamp, false otherwise.
391 */
392 public static function validate_iso8601_timestamp( string $timestamp ): bool {
393 // Try to parse as DateTime with ISO 8601 format.
394 $datetime = DateTime::createFromFormat( DateTime::ATOM, $timestamp );
395 if ( $datetime && $datetime->format( DateTime::ATOM ) === $timestamp ) {
396 return true;
397 }
398
399 // Try alternative ISO 8601 formats.
400 $formats = array(
401 'Y-m-d\TH:i:s\Z', // UTC format
402 'Y-m-d\TH:i:sP', // With timezone offset
403 'Y-m-d\TH:i:s.u\Z', // With microseconds UTC
404 'Y-m-d\TH:i:s.uP', // With microseconds and timezone
405 );
406
407 foreach ( $formats as $format ) {
408 $datetime = DateTime::createFromFormat( $format, $timestamp );
409 if ( $datetime && $datetime->format( $format ) === $timestamp ) {
410 return true;
411 }
412 }
413
414 return false;
415 }
416
417 /**
418 * Validate a priority value according to MCP specification.
419 *
420 * Priority must be a number between 0.0 and 1.0 (inclusive).
421 *
422 * @param mixed $priority The priority value to validate.
423 *
424 * @return bool True if valid, false otherwise.
425 */
426 public static function validate_priority( $priority ): bool {
427 if ( ! is_numeric( $priority ) ) {
428 return false;
429 }
430
431 $priority_float = (float) $priority;
432
433 return $priority_float >= 0.0 && $priority_float <= 1.0;
434 }
435
436 /**
437 * Normalize a `_meta` value for inclusion in a protocol DTO.
438 *
439 * MCP declares `_meta` as `{ [key: string]: unknown }` — a JSON object. PHP has one
440 * array type for both JSON shapes, so a sequential array (including an empty one)
441 * would serialize to a JSON array and put non-conformant output on the wire. Those
442 * are treated as absent, as is any non-array value.
443 *
444 * Returns null rather than raising so an incorrectly shaped optional `_meta` does
445 * not withhold the payload it accompanies.
446 *
447 * @since 0.6.0
448 *
449 * @param mixed $meta The raw `_meta` value.
450 *
451 * @return array<array-key, mixed>|null A non-empty, non-list array suitable for JSON-object encoding, or null if absent/invalid.
452 */
453 public static function normalize_meta( $meta ): ?array {
454 if ( ! is_array( $meta ) || array() === $meta ) {
455 return null;
456 }
457
458 // A list serializes to a JSON array. array_is_list() needs PHP 8.1; the floor is 7.4.
459 if ( array_keys( $meta ) === range( 0, count( $meta ) - 1 ) ) {
460 return null;
461 }
462
463 return $meta;
464 }
465
466 /**
467 * Validate a resource URI format.
468 *
469 * Per MCP spec: "The URI can use any protocol; it is up to the server how to interpret it."
470 * This validates basic URI structure per RFC 3986.
471 *
472 * @param string $uri The URI to validate.
473 *
474 * @return bool True if valid, false otherwise.
475 */
476 public static function validate_resource_uri( string $uri ): bool {
477 // URI should not be empty.
478 if ( empty( $uri ) ) {
479 return false;
480 }
481
482 // Check reasonable length constraints.
483 if ( strlen( $uri ) > 2048 ) {
484 return false;
485 }
486
487 // Basic URI validation: must have scheme followed by colon (RFC 3986).
488 // This accepts any protocol as per MCP specification.
489 return (bool) preg_match( '/^' . self::URI_SCHEME_PATTERN . ':.+/', $uri );
490 }
491
492 /**
493 * Lowercase the scheme (the part before the first ":") of a URI.
494 *
495 * URI schemes are case-insensitive per RFC 3986, so "Foo://x" and "foo://x"
496 * identify the same resource. Lowercasing the scheme on both sides of a
497 * comparison lets the two forms match. Everything after the scheme is left
498 * untouched, because case may be meaningful there.
499 *
500 * @param string $uri Resource URI.
501 *
502 * @return string Same URI with a lowercased scheme.
503 * @since 0.6.0
504 */
505 public static function fold_uri_scheme( string $uri ): string {
506 // On PCRE failure preg_replace_callback() returns null; keep the URI as-is.
507 return preg_replace_callback(
508 '/^(' . self::URI_SCHEME_PATTERN . '):/',
509 static fn( array $matches ): string => strtolower( $matches[1] ) . ':',
510 $uri
511 ) ?? $uri;
512 }
513 }
514