From baea9dee4faf283e59f28f002d6149b3f80b85ca Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 02:50:28 +0200 Subject: [PATCH 1/5] [Schema] Refuse an invalid x-mcp-header annotation at definition time SEP-2243 calls a tool definition invalid when its x-mcp-header annotation is empty, is not an HTTP field name, collides case-insensitively with another, or sits on a number, array or object property - none of which can be mirrored onto a header. Nothing said so. The earliest place to is where the tool is defined, rather than on the request that trips over it. --- CHANGELOG.md | 1 + src/Schema/Tool.php | 91 ++++++++++ .../Unit/Schema/ToolHeaderAnnotationTest.php | 160 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/Unit/Schema/ToolHeaderAnnotationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c02faf30..afba6d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Refuse an invalid `x-mcp-header` annotation where a tool defines it (SEP-2243). `Tool` now rejects a definition whose `x-mcp-header` is empty, is not an HTTP field name (CR/LF injection included), collides case-insensitively with another, or sits on a `number`, array or object property — the specification calls such a definition invalid, and the earliest place to say so is where the tool is defined, not on the request that trips over it. * [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. diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index b5aee7e2..14cb5497 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -125,6 +125,97 @@ public function __construct( // sub-schemas — not only SchemaGenerator / fromArray. $this->inputSchema = self::normalizeSchema($inputSchema); $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; + + // An out-of-bounds `x-mcp-header` makes the whole tool definition + // invalid, so it is refused where the tool is defined rather than + // discovered when a header comparison mysteriously fails. + if (null !== $reason = self::checkHeaderAnnotations($this->inputSchema)) { + throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); + } + } + + /** + * Validates every `x-mcp-header` annotation in an input schema (SEP-2243). + * + * The value becomes an HTTP field name, so it has to be one; it has to be + * unique case-insensitively, or two arguments would fight over one header; + * and it may only sit on a primitive that is not `number`, because a float + * has no single decimal spelling for a receiver to compare against. + * + * @param array $inputSchema + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + public static function checkHeaderAnnotations(array $inputSchema): ?string + { + $seen = []; + + foreach (self::headerAnnotations($inputSchema) as [$name, $type, $path]) { + if ('' === $name) { + return \sprintf('the annotation at "%s" is empty', $path); + } + + // RFC 9110 tchar; excludes CR, LF and every other control character. + if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + return \sprintf('"%s" is not a valid HTTP field name', $name); + } + + $folded = strtolower($name); + if (isset($seen[$folded])) { + return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); + } + $seen[$folded] = $path; + + if ('number' === $type) { + return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); + } + + if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { + return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); + } + } + + return null; + } + + /** + * Every annotation reachable through `properties` alone, as name, declared + * type and dotted path. + * + * @param array $schema + * + * @return list + */ + private static function headerAnnotations(array $schema, string $prefix = ''): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; + $annotation = $definition['x-mcp-header'] ?? null; + + if (null !== $annotation) { + if (!\is_string($annotation)) { + $found[] = ['', null, $path]; + } else { + $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; + } + } + + $found = [...$found, ...self::headerAnnotations($definition, $path)]; + } + + return $found; } /** diff --git a/tests/Unit/Schema/ToolHeaderAnnotationTest.php b/tests/Unit/Schema/ToolHeaderAnnotationTest.php new file mode 100644 index 00000000..8fab2820 --- /dev/null +++ b/tests/Unit/Schema/ToolHeaderAnnotationTest.php @@ -0,0 +1,160 @@ + $properties + */ + private static function tool(array $properties): Tool + { + return new Tool( + name: 'a_tool', + title: null, + inputSchema: ['type' => 'object', 'properties' => $properties, 'required' => null], + description: 'x', + annotations: null, + ); + } + + #[TestDox('a well-formed annotation is accepted')] + public function testValidAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'dry_run' => ['type' => 'boolean', 'x-mcp-header' => 'Dry-Run'], + 'query' => ['type' => 'string'], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + #[TestDox('an annotation on a nested property is accepted: the chain is all properties')] + public function testNestedAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + /** + * @return iterable, string}> + */ + public static function invalidAnnotations(): iterable + { + yield 'empty name' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => '']], + 'is empty', + ]; + + yield 'name with a space' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'My Header']], + 'not a valid HTTP field name', + ]; + + yield 'name with a newline' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\nInjected: yes"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a carriage return' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\rY"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a colon' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'X:Y']], + 'not a valid HTTP field name', + ]; + + yield 'duplicate, differing only in case' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'b' => ['type' => 'string', 'x-mcp-header' => 'region'], + ], + 'declared twice', + ]; + + yield 'on a number' => [ + ['a' => ['type' => 'number', 'x-mcp-header' => 'Amount']], + 'cannot be mirrored', + ]; + + yield 'on an array' => [ + ['a' => ['type' => 'array', 'items' => ['type' => 'string'], 'x-mcp-header' => 'Tags']], + 'only string, integer and boolean', + ]; + + yield 'on an object' => [ + ['a' => ['type' => 'object', 'x-mcp-header' => 'Blob']], + 'only string, integer and boolean', + ]; + + yield 'non-string annotation value' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 42]], + 'is empty', + ]; + + yield 'duplicate across nesting levels' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'nested' => [ + 'type' => 'object', + 'properties' => ['b' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'declared twice', + ]; + } + + /** + * @param array $properties + */ + #[DataProvider('invalidAnnotations')] + #[TestDox('an out-of-bounds annotation makes the tool definition invalid')] + public function testInvalidAnnotationIsRefused(array $properties, string $reason): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/'.preg_quote($reason, '/').'/'); + + self::tool($properties); + } + + #[TestDox('an annotation the walk cannot reach statically is simply not seen')] + public function testUnreachableAnnotationIsIgnored(): void + { + // Under `items`, so it is not reachable through `properties` alone. + // The spec calls such a definition invalid; this SDK does not mirror + // what it cannot reach, and does not pretend the annotation exists. + $tool = self::tool([ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'x-mcp-header' => 'Bad Name With Spaces'], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } +} From 0268432e03357f0f649050a54a1eda6aca01e665 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 09:20:35 +0200 Subject: [PATCH 2/5] Clarify x-mcp-header validation only reaches properties --- src/Schema/Tool.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 14cb5497..cd64c9f7 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -126,16 +126,18 @@ public function __construct( $this->inputSchema = self::normalizeSchema($inputSchema); $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; - // An out-of-bounds `x-mcp-header` makes the whole tool definition - // invalid, so it is refused where the tool is defined rather than - // discovered when a header comparison mysteriously fails. + // An out-of-bounds `x-mcp-header` reachable through `properties` makes + // the whole tool definition invalid, so it is refused where the tool + // is defined rather than discovered when a header comparison + // mysteriously fails. if (null !== $reason = self::checkHeaderAnnotations($this->inputSchema)) { throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); } } /** - * Validates every `x-mcp-header` annotation in an input schema (SEP-2243). + * Validates every `x-mcp-header` annotation reachable through `properties` + * in an input schema (SEP-2243). * * The value becomes an HTTP field name, so it has to be one; it has to be * unique case-insensitively, or two arguments would fight over one header; From 5fe23e1eecff0456de2ca9a8f20fe306c4911bb4 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 19:00:05 +0200 Subject: [PATCH 3/5] Make checkHeaderAnnotations() a private instance method --- src/Schema/Tool.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index cd64c9f7..374a2efb 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -130,7 +130,7 @@ public function __construct( // the whole tool definition invalid, so it is refused where the tool // is defined rather than discovered when a header comparison // mysteriously fails. - if (null !== $reason = self::checkHeaderAnnotations($this->inputSchema)) { + if (null !== $reason = $this->checkHeaderAnnotations()) { throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); } } @@ -144,15 +144,13 @@ public function __construct( * and it may only sit on a primitive that is not `number`, because a float * has no single decimal spelling for a receiver to compare against. * - * @param array $inputSchema - * * @return string|null the reason it is invalid, or null when every annotation is well-formed */ - public static function checkHeaderAnnotations(array $inputSchema): ?string + private function checkHeaderAnnotations(): ?string { $seen = []; - foreach (self::headerAnnotations($inputSchema) as [$name, $type, $path]) { + foreach (self::headerAnnotations($this->inputSchema) as [$name, $type, $path]) { if ('' === $name) { return \sprintf('the annotation at "%s" is empty', $path); } From ba624c4dc226a597095e5a4a7c7801070a123a94 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 19:54:07 +0200 Subject: [PATCH 4/5] Make headerAnnotations() a private instance method --- src/Schema/Tool.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 374a2efb..4fd87f41 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -150,7 +150,7 @@ private function checkHeaderAnnotations(): ?string { $seen = []; - foreach (self::headerAnnotations($this->inputSchema) as [$name, $type, $path]) { + foreach ($this->headerAnnotations($this->inputSchema) as [$name, $type, $path]) { if ('' === $name) { return \sprintf('the annotation at "%s" is empty', $path); } @@ -186,7 +186,7 @@ private function checkHeaderAnnotations(): ?string * * @return list */ - private static function headerAnnotations(array $schema, string $prefix = ''): array + private function headerAnnotations(array $schema, string $prefix = ''): array { $properties = $schema['properties'] ?? null; @@ -212,7 +212,7 @@ private static function headerAnnotations(array $schema, string $prefix = ''): a } } - $found = [...$found, ...self::headerAnnotations($definition, $path)]; + $found = [...$found, ...$this->headerAnnotations($definition, $path)]; } return $found; From 98864dbe86b5c45c1a2f3817826a5ff9b5657b6a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 19:58:18 +0200 Subject: [PATCH 5/5] Apply suggestion from @chr-hertel --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afba6d5b..c02faf30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- -* Refuse an invalid `x-mcp-header` annotation where a tool defines it (SEP-2243). `Tool` now rejects a definition whose `x-mcp-header` is empty, is not an HTTP field name (CR/LF injection included), collides case-insensitively with another, or sits on a `number`, array or object property — the specification calls such a definition invalid, and the earliest place to say so is where the tool is defined, not on the request that trips over it. * [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.