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 readonly 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;
41: private ValidationErrorFormatter $formatter;
42:
43: public function __construct()
44: {
45: // `SafeDisplay` caps the composed diagnostic at 256 characters, so a deeper walk is never peer-visible.
46: $this->validator = new Validator(max_errors: self::MAX_ERRORS);
47: $this->formatter = new ValidationErrorFormatter();
48: }
49:
50: #[\Override]
51: public function validate(mixed $data, array $schema): array
52: {
53: $error = $this->validator->validate(
54: Helper::toJSON($data),
55: (object) Helper::toJSON($this->normaliseSubSchemas($schema)),
56: )->error();
57:
58: return null === $error ? [] : $this->formatter->format($error);
59: }
60:
61: /**
62: * Restores the always-valid `{}` that `json_decode(..., true)` renders as PHP `[]` in every sub-schema position.
63: *
64: * @param array<array-key, mixed> $schema
65: *
66: * @return array<array-key, mixed>
67: */
68: private function normaliseSubSchemas(array $schema): array
69: {
70: foreach (self::SCHEMA_KEYWORDS as $keyword) {
71: if (isset($schema[$keyword]) && \is_array($schema[$keyword])) {
72: $schema[$keyword] = $this->castToSchemaObject($schema[$keyword]);
73: }
74: }
75:
76: foreach (self::SCHEMA_MAP_KEYWORDS as $keyword) {
77: if (! isset($schema[$keyword]) || ! \is_array($schema[$keyword])) {
78: continue;
79: }
80:
81: $map = $schema[$keyword];
82:
83: if ([] === $map) {
84: $schema[$keyword] = new \stdClass();
85:
86: continue;
87: }
88:
89: foreach ($map as $name => $subSchema) {
90: if (\is_array($subSchema)) {
91: $map[$name] = $this->castToSchemaObject($subSchema);
92: }
93: }
94:
95: $schema[$keyword] = $map;
96: }
97:
98: foreach (self::SCHEMA_LIST_KEYWORDS as $keyword) {
99: if (! isset($schema[$keyword]) || ! \is_array($schema[$keyword])) {
100: continue;
101: }
102:
103: $list = $schema[$keyword];
104:
105: foreach ($list as $index => $subSchema) {
106: if (\is_array($subSchema)) {
107: $list[$index] = $this->castToSchemaObject($subSchema);
108: }
109: }
110:
111: $schema[$keyword] = $list;
112: }
113:
114: return $schema;
115: }
116:
117: /**
118: * @param array<array-key, mixed> $subSchema
119: *
120: * @return array<array-key, mixed>|\stdClass
121: */
122: private function castToSchemaObject(array $subSchema): array|\stdClass
123: {
124: return [] === $subSchema ? new \stdClass() : $this->normaliseSubSchemas($subSchema);
125: }
126: }
127: