FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

[Server][Capability] Bound what a schema can cost to validate (SEP-2106) by chr-hertel · Pull Request #436 · modelcontextprotocol/php-sdk · GitHub

Repository navigation

1 change: 1 addition & 0 deletions CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [BC Break] `Schema\JsonRpc\Error` accepts `null` as `$id`; an unreadable id now omits the member instead of sending `"id": ""`. `MessageFactory` decodes a missing or null id as an id-less error.
* Preserve the request `id` on an invalid-but-parseable message (`-32600`) via `InvalidInputMessageException::getRequestId()`.
* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name`; the spec allows any string.
* Refuse a JSON Schema that is unsafe or ruinous to validate before `opis/json-schema` walks it (SEP-2106): a `$ref` naming anything outside the document, and a composition expanding past a subschema budget, nesting depth or property-map size. New `Capability\Discovery\SchemaComplexityGuard`, wired into `SchemaValidator` by default and configurable through its constructor — sixteen nested two-branch `anyOf`s went from 9.0s to refused in 0.1s. `SchemaValidator` also caps reported errors at 100 and names an unsupported `$schema` dialect instead of reporting an internal fault.
* Log expected tool failures (`ToolCallException`) at debug level instead of error.
* Add `annotations` to `ImageContent`.
* Fix empty tool/resource schemas serializing as `[]` instead of `{}`.
Expand Down
315 changes: 315 additions & 0 deletions src/Capability/Discovery/SchemaComplexityGuard.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
<?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;

/**
* Refuses a JSON Schema that would be expensive or unsafe to validate.
*
* Two hazards, both called out by the specification's JSON Schema rules:
*
* **External `$ref`.** A `$ref` may name an absolute URI, and dereferencing one
* turns every schema into a request the sender chose — an SSRF primitive
* pointed at whatever the validating host can reach. Implementations MUST NOT
* dereference network references automatically. This SDK does not resolve
* anything outside the document at all, so the guard's job is to say so up
* front rather than let it surface as "unresolved reference", which reads like
* an internal fault and hides why the schema was refused.
*
* **Composition blow-up.** `anyOf`/`oneOf`/`allOf` and `$defs` compose
* multiplicatively: sixteen nested two-branch `anyOf`s are a few kilobytes on
* the wire and 65 536 subschema evaluations to validate, and the same shape
* written with `$defs` and `$ref` is a few hundred bytes. Opis's
* `setMaxErrors()` does not help — measured, it caps what is reported, not what
* is walked — so the bound has to be structural and applied before validation.
*
* The estimate resolves same-document `$ref`s and sums branch costs, which is
* what makes the exponential visible while the schema is still small. Targets
* are memoised, so the cheap-on-the-wire `$defs` form costs the same to judge
* as the expanded one. A cycle counts as a single step: recursive schemas are
* legitimate and terminate on real data.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class SchemaComplexityGuard
{
/**
* Keywords whose value is a map of name to subschema, rather than a
* subschema itself. Their keys are user-chosen and must not be read as
* keywords.
*/
private const SCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas'];

/**
* @param int $maxDepth how deeply subschemas may nest
* @param int $maxSubschemas ceiling on estimated subschema evaluations
* @param int $maxProperties ceiling on named subschemas in any one map
*/
public function __construct(
private readonly int $maxDepth = 32,
private readonly int $maxSubschemas = 10_000,
private readonly int $maxProperties = 1_000,
) {
}

/**
* @param array<string, mixed>|object $schema
*
* @return string|null the reason to refuse, or null when the schema is within bounds
*/
private static function gtrace(string $m): void
{
file_put_contents('php://stderr', '[GTRACE] '.$m."\n", \FILE_APPEND);
}

public function check(array|object $schema): ?string
{
self::gtrace('toArray:in type='.get_debug_type($schema));

try {
$root = self::toArray($schema);
} catch (\JsonException $e) {
return \sprintf('Schema could not be decoded as JSON: %s', $e->getMessage());
}

$json = (string) json_encode($root);
self::gtrace('toArray:out bytes='.\strlen($json).' keys='.implode(',', array_slice(array_keys($root), 0, 12)));
self::gtrace('schema='.substr($json, 0, 1500));

self::gtrace('extref:in');
if (null !== $reason = $this->findExternalRef($root, 0)) {
self::gtrace('extref:refused');

return $reason;
}
self::gtrace('extref:out');

try {
self::gtrace('cost:in');
$this->cost($root, $root, [], 0, new \stdClass());
self::gtrace('cost:out');
} catch (\OverflowException $e) {
self::gtrace('cost:overflow');

return $e->getMessage();
}

return null;
}

/**
* @param array<string, mixed> $node
*/
private function findExternalRef(array $node, int $depth): ?string
{
if ($depth > $this->maxDepth) {
return \sprintf('Schema nests deeper than the %d levels this validator accepts.', $this->maxDepth);
}

foreach ($node as $key => $value) {
if ('$ref' === $key && \is_string($value) && !str_starts_with($value, '#')) {
return \sprintf('Schema contains the non-local reference "%s"; only same-document "#" references are resolved.', $value);
}

if (\is_array($value) && null !== $reason = $this->findExternalRef($value, $depth + 1)) {
return $reason;
}
}

return null;
}

/**
* Estimated subschema evaluations $node can trigger.
*
* @param array<string, mixed> $node
* @param array<string, mixed> $root
* @param list<string> $stack pointers currently being resolved, so a cycle is not followed twice
* @param \stdClass $memo cost per already-resolved pointer
*
* @throws \OverflowException as soon as the running estimate passes the ceiling
*/
private function cost(array $node, array $root, array $stack, int $depth, object $memo): int
{
if ($depth > $this->maxDepth) {
throw new \OverflowException(\sprintf('Schema nests deeper than the %d levels this validator accepts.', $this->maxDepth));
}

if (isset($node['$ref']) && \is_string($node['$ref'])) {
return $this->refCost($node['$ref'], $root, $stack, $depth, $memo);
}

$total = 1;

foreach ($node as $key => $value) {
if (!\is_array($value)) {
continue;
}

if (\in_array($key, self::SCHEMA_MAPS, true)) {
if (\count($value) > $this->maxProperties) {
throw new \OverflowException(\sprintf('Schema declares more than %d entries under "%s".', $this->maxProperties, $key));
}

foreach ($value as $subschema) {
if (\is_array($subschema)) {
$total += $this->cost($subschema, $root, $stack, $depth + 1, $memo);
}
}

$this->assertWithinBudget($total);

continue;
}

// Everything else holding an array is either a subschema or a list
// of them; a keyword holding plain data contributes nothing but is
// harmless to walk, since only its own nesting is counted.
if (array_is_list($value)) {
foreach ($value as $subschema) {
if (\is_array($subschema)) {
$total += $this->cost($subschema, $root, $stack, $depth + 1, $memo);
}
}
} else {
$total += $this->cost($value, $root, $stack, $depth + 1, $memo);
}

$this->assertWithinBudget($total);
}

return $total;
}

/**
* Chases a same-document `$ref`, and every bare `$ref` it in turn points
* to, without recursing: a node that is only `{"$ref": ...}` contributes
* nothing of its own, so a schema chaining many of them (a "flat" `$defs`
* indirection) is meant to be free regardless of length. Resolving that
* chain by mutual recursion with {@see cost()} spent one native call
* frame per link, so a chain long enough — a size none of the other
* bounds catch, since a chain's cost is deliberately independent of its
* length — exhausted the stack or the memory backing it before this
* class ever got to refuse anything. Walking the chain in a loop keeps
* this at constant stack depth; only the schema found at the end of it,
* if any, is handed to cost() for its own depth-bounded recursion.
*
* @param array<string, mixed> $root
* @param list<string> $stack pointers being resolved by an enclosing call
*/
private function refCost(string $pointer, array $root, array $stack, int $depth, object $memo): int
{
$visited = [];

while (true) {
// A back-edge: recursive schemas are legitimate, and how far one
// unrolls is decided by the data, not the schema.
if (\in_array($pointer, $stack, true) || isset($visited[$pointer])) {
return $this->memoizeAll($visited, 1, $memo);
}

if (isset($memo->{$pointer})) {
return $this->memoizeAll($visited, $memo->{$pointer}, $memo);
}

$target = self::resolve($pointer, $root);

if (null === $target) {
// Unresolvable same-document pointers are the validator's
// business to report; nothing here can be expensive.
return $this->memoizeAll($visited, 1, $memo);
}

$visited[$pointer] = true;

if (!isset($target['$ref']) || !\is_string($target['$ref'])) {
// Depth is lexical nesting, which following a reference is
// not: a long chain of `$defs` referring to one another is
// flat and cheap. What bounds this is the subschema budget
// and the cycle check above, and the pointer set is finite,
// so the walk is too.
$cost = $this->cost($target, $root, [...$stack, ...array_keys($visited)], $depth, $memo);

return $this->memoizeAll($visited, $cost, $memo);
}

$pointer = $target['$ref'];
}
}

/**
* @param array<string, true> $pointers
*/
private function memoizeAll(array $pointers, int $cost, object $memo): int
{
foreach ($pointers as $pointer => $_) {
$memo->{$pointer} = $cost;
}

return $cost;
}

/**
* Resolves a same-document JSON pointer (`#`, `#/$defs/name`).
*
* @param array<string, mixed> $root
*
* @return array<string, mixed>|null
*/
private static function resolve(string $pointer, array $root): ?array
{
if ('#' === $pointer) {
return $root;
}

if (!str_starts_with($pointer, '#/')) {
return null;
}

$node = $root;

foreach (explode('/', substr($pointer, 2)) as $segment) {
$segment = str_replace(['~1', '~0'], ['/', '~'], rawurldecode($segment));

if (!\is_array($node) || !\array_key_exists($segment, $node)) {
return null;
}

$node = $node[$segment];
}

return \is_array($node) ? $node : null;
}

private function assertWithinBudget(int $total): void
{
if ($total > $this->maxSubschemas) {
throw new \OverflowException(\sprintf('Schema composes more than %d subschemas, which this validator refuses to walk.', $this->maxSubschemas));
}
}

/**
* @param array<string, mixed>|object $schema
*
* @return array<string, mixed>
*/
private static function toArray(array|object $schema): array
{
if (\is_array($schema)) {
return $schema;
}

/** @var array<string, mixed> $decoded */
$decoded = json_decode(json_encode($schema, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR);

return $decoded;
}
}
Loading
Loading

Back | FazBrowse Home | New Git URL