php-sdk/src/Capability/Discovery/SchemaValidator.php
2025-08-29 11:18:33 +02:00

336 lines
13 KiB
PHP

<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use Mcp\Exception\InvalidArgumentException;
use Opis\JsonSchema\Errors\ValidationError;
use Opis\JsonSchema\Validator;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Validates data against JSON Schema definitions using opis/json-schema.
*
* @phpstan-type Error array{
* pointer: string,
* keyword: string,
* message: string,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SchemaValidator
{
private ?Validator $jsonSchemaValidator = null;
public function __construct(
private LoggerInterface $logger = new NullLogger(),
) {
}
/**
* Validates data against a JSON schema.
*
* @param mixed $data the data to validate (should generally be decoded JSON)
* @param array<string, mixed>|object $schema the JSON Schema definition (as PHP array or object)
*
* @return list<array{pointer: string, keyword: string, message: string}> array of validation errors, empty if valid
*/
public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
{
if (\is_array($data) && empty($data)) {
$data = new \stdClass();
}
try {
// --- Schema Preparation ---
if (\is_array($schema)) {
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
} elseif (\is_object($schema)) {
// This might be overly cautious but safer against varied inputs.
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
} else {
throw new InvalidArgumentException('Schema must be an array or object.');
}
// --- Data Preparation ---
// Opis Validator generally prefers objects for object validation
$dataToValidate = $this->convertDataForValidator($data);
} catch (\JsonException $e) {
$this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
} catch (InvalidArgumentException $e) {
$this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
} catch (\Throwable $e) {
$this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
}
$validator = $this->getJsonSchemaValidator();
try {
$result = $validator->validate($dataToValidate, $schemaObject);
} catch (\Throwable $e) {
$this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
'exception_message' => $e->getMessage(),
'exception_trace' => $e->getTraceAsString(),
'data' => json_encode($dataToValidate),
'schema' => json_encode($schemaObject),
]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
}
if ($result->isValid()) {
return [];
}
$formattedErrors = [];
$topError = $result->error();
if ($topError) {
$this->collectSubErrors($topError, $formattedErrors);
}
if (empty($formattedErrors) && $topError) { // Fallback
$formattedErrors[] = [
'pointer' => $this->formatJsonPointerPath($topError->data()->path()),
'keyword' => $topError->keyword(),
'message' => $this->formatValidationError($topError),
];
}
return $formattedErrors;
}
/**
* Get or create the JSON Schema validator instance.
*/
private function getJsonSchemaValidator(): Validator
{
if (null === $this->jsonSchemaValidator) {
$this->jsonSchemaValidator = new Validator();
// Potentially configure resolver here if needed later
}
return $this->jsonSchemaValidator;
}
/**
* Recursively converts associative arrays to stdClass objects for validator compatibility.
*/
private function convertDataForValidator(mixed $data): mixed
{
if (\is_array($data)) {
// Check if it's an associative array (keys are not sequential numbers 0..N-1)
if (!empty($data) && array_keys($data) !== range(0, \count($data) - 1)) {
$obj = new \stdClass();
foreach ($data as $key => $value) {
$obj->{$key} = $this->convertDataForValidator($value);
}
return $obj;
} else {
// It's a list (sequential array), convert items recursively
return array_map([$this, 'convertDataForValidator'], $data);
}
} elseif (\is_object($data) && $data instanceof \stdClass) {
// Deep copy/convert stdClass objects as well
$obj = new \stdClass();
foreach (get_object_vars($data) as $key => $value) {
$obj->{$key} = $this->convertDataForValidator($value);
}
return $obj;
}
// Leave other objects and scalar types as they are
return $data;
}
/**
* Recursively collects leaf validation errors.
*
* @param Error[] $collectedErrors
*/
private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
{
$subErrors = $error->subErrors();
if (empty($subErrors)) {
$collectedErrors[] = [
'pointer' => $this->formatJsonPointerPath($error->data()->path()),
'keyword' => $error->keyword(),
'message' => $this->formatValidationError($error),
];
} else {
foreach ($subErrors as $subError) {
$this->collectSubErrors($subError, $collectedErrors);
}
}
}
/**
* Formats the path array into a JSON Pointer string.
*
* @param string[]|int[]|null $pathComponents
*/
private function formatJsonPointerPath(?array $pathComponents): string
{
if (empty($pathComponents)) {
return '/';
}
$escapedComponents = array_map(function ($component) {
$componentStr = (string) $component;
return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
}, $pathComponents);
return '/'.implode('/', $escapedComponents);
}
/**
* Formats an Opis SchemaValidationError into a user-friendly message.
*/
private function formatValidationError(ValidationError $error): string
{
$keyword = $error->keyword();
$args = $error->args();
$message = "Constraint `{$keyword}` failed.";
switch (strtolower($keyword)) {
case 'required':
$missing = $args['missing'] ?? [];
$formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing));
$message = "Missing required properties: {$formattedMissing}.";
break;
case 'type':
$expected = implode('|', (array) ($args['expected'] ?? []));
$used = $args['used'] ?? 'unknown';
$message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
break;
case 'enum':
$schemaData = $error->schema()->info()->data();
$allowedValues = [];
if (\is_object($schemaData) && property_exists($schemaData, 'enum') && \is_array($schemaData->enum)) {
$allowedValues = $schemaData->enum;
} elseif (\is_array($schemaData) && isset($schemaData['enum']) && \is_array($schemaData['enum'])) {
$allowedValues = $schemaData['enum'];
} else {
$this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
}
if (empty($allowedValues)) {
$message = 'Value does not match the allowed enumeration.';
} else {
$formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */
if (\is_string($v)) {
return '"'.$v.'"';
}
if (\is_bool($v)) {
return $v ? 'true' : 'false';
}
if (null === $v) {
return 'null';
}
return (string) $v;
}, $allowedValues);
$message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.';
}
break;
case 'const':
$expected = json_encode($args['expected'] ?? 'null', \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
$message = "Value must be equal to the constant value: {$expected}.";
break;
case 'minLength': // Corrected casing
$min = $args['min'] ?? '?';
$message = "String must be at least {$min} characters long.";
break;
case 'maxLength': // Corrected casing
$max = $args['max'] ?? '?';
$message = "String must not be longer than {$max} characters.";
break;
case 'pattern':
$pattern = $args['pattern'] ?? '?';
$message = "String does not match the required pattern: `{$pattern}`.";
break;
case 'minimum':
$min = $args['min'] ?? '?';
$message = "Number must be greater than or equal to {$min}.";
break;
case 'maximum':
$max = $args['max'] ?? '?';
$message = "Number must be less than or equal to {$max}.";
break;
case 'exclusiveMinimum': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Number must be strictly greater than {$min}.";
break;
case 'exclusiveMaximum': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Number must be strictly less than {$max}.";
break;
case 'multipleOf': // Corrected casing
$value = $args['value'] ?? '?';
$message = "Number must be a multiple of {$value}.";
break;
case 'minItems': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Array must contain at least {$min} items.";
break;
case 'maxItems': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Array must contain no more than {$max} items.";
break;
case 'uniqueItems': // Corrected casing
$message = 'Array items must be unique.';
break;
case 'minProperties': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Object must have at least {$min} properties.";
break;
case 'maxProperties': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Object must have no more than {$max} properties.";
break;
case 'additionalProperties': // Corrected casing
$unexpected = $args['properties'] ?? [];
$formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected));
$message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
break;
case 'format':
$format = $args['format'] ?? 'unknown';
$message = "Value does not match the required format: `{$format}`.";
break;
default:
$builtInMessage = $error->message();
if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) {
$placeholders = $args;
$builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) {
$key = $match[1];
$value = $placeholders[$key] ?? '{'.$key.'}';
return \is_array($value) ? json_encode($value) : (string) $value;
}, $builtInMessage);
$message = $builtInMessage;
}
break;
}
return $message;
}
}