Skip to content

[Capability] Bound what a schema can cost to validate (SEP-2106) - #436

Open
chr-hertel wants to merge 3 commits into
modelcontextprotocol:mainfrom
chr-hertel:pr/schema-complexity-guard
Open

[Capability] Bound what a schema can cost to validate (SEP-2106)#436
chr-hertel wants to merge 3 commits into
modelcontextprotocol:mainfrom
chr-hertel:pr/schema-complexity-guard

Conversation

@chr-hertel

Copy link
Copy Markdown
Member

New Mcp\Capability\Discovery\SchemaComplexityGuard, wired into SchemaValidator by default and configurable through its constructor, refuses two shapes before opis/json-schema walks them:

  • A $ref naming 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 $ref failed as an opaque "unresolved reference". The guard states the rule up front and tests it.
  • A composition that expands past a subschema budget, a nesting depth, or a property-map size. This one was a real hole. Sixteen nested two-branch 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.

SchemaValidator also caps reported errors at 100, and reports an unsupported $schema dialect as such, naming the dialect, rather than as an opaque internal fault.

Part of SEP-2106.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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\SchemaComplexityGuard to reject non-local $ref and structurally “ruinous” schemas (depth/subschema/property-map ceilings).
  • Wire the guard into SchemaValidator by default (configurable via constructor), cap reported errors at 100, and improve messaging for unsupported $schema dialects.
  • 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.

Comment on lines +68 to +83
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;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0672eb9check() now catches \JsonException from toArray() and returns it as a refusal reason instead of leaking it.

Comment on lines +210 to +214
private static function resolve(string $pointer, array $root): ?array
{
if ('#' === $pointer || '' === $pointer) {
return $root;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0672eb9 — dropped the unreachable '' === $pointer branch in resolve().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants