Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0
-----

* 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 that expands past a subschema budget, nesting depth or property-map size. New `Mcp\Capability\Discovery\SchemaComplexityGuard`, wired into `SchemaValidator` by default — 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.
* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged.
* Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`.
* Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively.
Expand Down
296 changes: 296 additions & 0 deletions src/Capability/Discovery/SchemaComplexityGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
<?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
*/
public function check(array|object $schema): ?string
{
try {
$root = self::toArray($schema);
} catch (\JsonException $e) {
return \sprintf('Schema could not be decoded as JSON: %s', $e->getMessage());
}

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;
}

/**
* @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;
}
}
41 changes: 40 additions & 1 deletion src/Capability/Discovery/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,23 @@
*/
class SchemaValidator
{
/**
* Ceiling on reported errors. Opis walks the whole schema regardless — this
* only bounds the array built out of it, which a composition blow-up can
* make the larger cost of the two. {@see SchemaComplexityGuard} is what
* bounds the walk.
*/
private const MAX_REPORTED_ERRORS = 100;

private ?Validator $jsonSchemaValidator = null;

private SchemaComplexityGuard $complexityGuard;

public function __construct(
private LoggerInterface $logger = new NullLogger(),
?SchemaComplexityGuard $complexityGuard = null,
) {
$this->complexityGuard = $complexityGuard ?? new SchemaComplexityGuard();
}

/**
Expand Down Expand Up @@ -81,6 +93,14 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
}

// Before the validator sees it: a schema can be cheap to send and
// ruinous to walk, and refusing it is only possible up front.
if (null !== $reason = $this->complexityGuard->check($schemaObject)) {
$this->logger->warning('MCP SDK: Refused a schema the complexity guard rejected.', ['reason' => $reason]);

return [['pointer' => '', 'keyword' => 'schema', 'message' => $reason]];
}

$validator = $this->getJsonSchemaValidator();

try {
Expand All @@ -92,6 +112,13 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar
'schema' => json_encode($schemaObject),
]);

// "Unsupported draft-XXXX" is the one failure here that is the
// schema's doing rather than ours, and the spec asks for an error
// that names the dialect.
if (str_contains($e->getMessage(), 'Unsupported draft')) {
return [['pointer' => '', 'keyword' => '$schema', 'message' => \sprintf('Unsupported JSON Schema dialect: %s. This validator supports 2020-12 (the default when no "$schema" is given) and the drafts opis/json-schema implements.', $e->getMessage())]];
}

return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
}

Expand Down Expand Up @@ -124,7 +151,12 @@ private function getJsonSchemaValidator(): Validator
{
if (null === $this->jsonSchemaValidator) {
$this->jsonSchemaValidator = new Validator();
// Potentially configure resolver here if needed later
$this->jsonSchemaValidator->setMaxErrors(self::MAX_REPORTED_ERRORS);
// No resolver is registered, and none should be: a `$ref` naming an
// absolute URI must never be fetched, which is a MUST in the
// specification's JSON Schema rules. SchemaComplexityGuard refuses
// such a schema before it reaches here, so this is the second of
// two locks rather than the only one.
}

return $this->jsonSchemaValidator;
Expand Down Expand Up @@ -169,6 +201,13 @@ private function convertDataForValidator(mixed $data): mixed
*/
private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
{
// The error tree fans out with the schema, so a composition-heavy
// schema produces far more leaves than Opis's own cap admits. Past the
// ceiling there is nothing left to learn from another one.
if (\count($collectedErrors) >= self::MAX_REPORTED_ERRORS) {
return;
}

$subErrors = $error->subErrors();
if (empty($subErrors)) {
$collectedErrors[] = [
Expand Down
Loading