1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server\Validation;
15:
16: use Opis\JsonSchema\Helper;
17: use Opis\JsonSchema\Validator;
18:
19: /**
20: * Default `SchemaValidatorInterface` backed by opis/json-schema.
21: */
22: final class OpisSchemaValidator implements SchemaValidatorInterface
23: {
24: private const array SCHEMA_KEYWORDS = [
25: 'additionalProperties',
26: 'contains',
27: 'else',
28: 'if',
29: 'items',
30: 'not',
31: 'propertyNames',
32: 'then',
33: 'unevaluatedItems',
34: 'unevaluatedProperties',
35: ];
36: private const array SCHEMA_MAP_KEYWORDS = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'];
37: private const array SCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
38: private const int MAX_ERRORS = 8;
39:
40: private ?Validator $validator = null;
41: private readonly ValidationErrorFormatter $formatter;
42:
43: /**
44: * @var array<string, object>
45: */
46: private array $schemas = [];
47:
48: public function __construct()
49: {
50: $this->formatter = new ValidationErrorFormatter();
51: }
52:
53: #[\Override]
54: public function validate(mixed $data, array $schema): array
55: {
56: // opis caches every schema object it parses for its own lifetime, so each distinct schema is built once.
57: $parsed = $this->schemas[json_encode($schema, \JSON_THROW_ON_ERROR)] ??= (object) Helper::toJSON(
58: $this->normaliseSubSchemas($schema),
59: );
60:
61: // `SafeDisplay` caps the composed diagnostic at 256 characters, so a deeper walk is never peer-visible.
62: $this->validator ??= new Validator(max_errors: self::MAX_ERRORS);
63:
64: $error = $this->validator->validate(Helper::toJSON($data), $parsed)->error();
65:
66: return null === $error ? [] : $this->formatter->format($error);
67: }
68:
69: /**
70: * Restores the always-valid `{}` that `json_decode(..., true)` renders as PHP `[]` in every sub-schema position.
71: *
72: * @param array<array-key, mixed> $schema
73: *
74: * @return array<array-key, mixed>
75: */
76: private function normaliseSubSchemas(array $schema): array
77: {
78: foreach (self::SCHEMA_KEYWORDS as $keyword) {
79: if (isset($schema[$keyword]) && \is_array($schema[$keyword])) {
80: $schema[$keyword] = $this->castToSchemaObject($schema[$keyword]);
81: }
82: }
83:
84: foreach (self::SCHEMA_MAP_KEYWORDS as $keyword) {
85: if (! isset($schema[$keyword]) || ! \is_array($schema[$keyword])) {
86: continue;
87: }
88:
89: $map = $schema[$keyword];
90:
91: if ([] === $map) {
92: $schema[$keyword] = new \stdClass();
93:
94: continue;
95: }
96:
97: foreach ($map as $name => $subSchema) {
98: if (\is_array($subSchema)) {
99: $map[$name] = $this->castToSchemaObject($subSchema);
100: }
101: }
102:
103: $schema[$keyword] = $map;
104: }
105:
106: foreach (self::SCHEMA_LIST_KEYWORDS as $keyword) {
107: if (! isset($schema[$keyword]) || ! \is_array($schema[$keyword])) {
108: continue;
109: }
110:
111: $list = $schema[$keyword];
112:
113: foreach ($list as $index => $subSchema) {
114: if (\is_array($subSchema)) {
115: $list[$index] = $this->castToSchemaObject($subSchema);
116: }
117: }
118:
119: $schema[$keyword] = $list;
120: }
121:
122: return $schema;
123: }
124:
125: /**
126: * @param array<array-key, mixed> $subSchema
127: *
128: * @return array<array-key, mixed>|\stdClass
129: */
130: private function castToSchemaObject(array $subSchema): array|\stdClass
131: {
132: return [] === $subSchema ? new \stdClass() : $this->normaliseSubSchemas($subSchema);
133: }
134: }
135: