[Capability] Bound what a schema can cost to validate (SEP-2106) - #436
[Capability] Bound what a schema can cost to validate (SEP-2106)#436chr-hertel wants to merge 3 commits into
Conversation
Refuses two shapes before opis/json-schema walks them (SEP-2106): a $ref naming anything outside the document, and a composition that expands past a subschema budget, a nesting depth, or a property-map size. The external $ref was already safe, but only by omission - the SDK registers no resolver, so it failed as an opaque "unresolved reference". The guard now states the rule up front. The composition bound was a real hole: sixteen nested two-branch anyOfs took 9.0s and 65536 error objects. Validator:: setMaxErrors() bounds the report, not the walk, so the guard is structural and runs first. The budget resolves same-document $refs, so the $defs- compressed form of the same bomb - a few hundred bytes on the wire - is caught along with the expanded one. Recursive schemas and long reference chains still pass. SchemaValidator also caps reported errors at 100, and reports an unsupported $schema dialect as such, naming it, instead of as an internal fault.
There was a problem hiding this comment.
Pull request overview
Introduces a schema “complexity guard” to preflight JSON Schemas used in capability discovery/tool argument validation, preventing unsafe $ref usage and bounding validation cost before opis/json-schema performs an expensive walk.
Changes:
- Add
Mcp\Capability\Discovery\SchemaComplexityGuardto reject non-local$refand structurally “ruinous” schemas (depth/subschema/property-map ceilings). - Wire the guard into
SchemaValidatorby default (configurable via constructor), cap reported errors at 100, and improve messaging for unsupported$schemadialects. - Add focused unit coverage for the guard and document the feature in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/Unit/Capability/Discovery/SchemaComplexityGuardTest.php | Adds unit tests covering external $ref rejection and complexity bounds behavior. |
| src/Capability/Discovery/SchemaValidator.php | Integrates the guard, caps/maxes errors, and improves unsupported dialect error reporting. |
| src/Capability/Discovery/SchemaComplexityGuard.php | New pre-validation guard implementing external $ref refusal and complexity estimation. |
| CHANGELOG.md | Documents the new guard, the error cap, and the improved dialect error message. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| public function check(array|object $schema): ?string | ||
| { | ||
| $root = self::toArray($schema); | ||
|
|
||
| if (null !== $reason = $this->findExternalRef($root, 0)) { | ||
| return $reason; | ||
| } | ||
|
|
||
| try { | ||
| $this->cost($root, $root, [], 0, new \stdClass()); | ||
| } catch (\OverflowException $e) { | ||
| return $e->getMessage(); | ||
| } | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
Fixed in 0672eb9 — check() now catches \JsonException from toArray() and returns it as a refusal reason instead of leaking it.
| private static function resolve(string $pointer, array $root): ?array | ||
| { | ||
| if ('#' === $pointer || '' === $pointer) { | ||
| return $root; | ||
| } |
There was a problem hiding this comment.
Fixed in 0672eb9 — dropped the unreachable '' === $pointer branch in resolve().
New
Mcp\Capability\Discovery\SchemaComplexityGuard, wired intoSchemaValidatorby default and configurable through its constructor, refuses two shapes beforeopis/json-schemawalks them:$refnaming anything outside the document — an SSRF primitive if it were ever dereferenced. The SDK registers no resolver, so this was already safe by omission: an external$reffailed as an opaque "unresolved reference". The guard states the rule up front and tests it.anyOfs took 9.0 s and produced 65,536 error objects.Validator::setMaxErrors()bounds the report, not the walk, so the guard has to be structural and run first. Measured after: refused in 0.1 s.The budget resolves same-document
$refs, so the$defs-compressed form of a composition bomb — a few hundred bytes on the wire, a million subschema evaluations to walk — is caught along with the expanded one. Recursive schemas and long reference chains still pass.SchemaValidatoralso caps reported errors at 100, and reports an unsupported$schemadialect as such, naming the dialect, rather than as an opaque internal fault.Part of SEP-2106.