diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index dcd4d374..07e1a176 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -149,22 +149,13 @@ private function checkParams(string $method, ?array $params, array $headers): ?s return null; } - $properties = $tool->inputSchema['properties'] ?? []; - if (!\is_array($properties)) { - return null; - } - $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; - foreach ($properties as $property => $definition) { - if (!\is_array($definition) || !\is_string($definition['x-mcp-header'] ?? null)) { - continue; - } - + foreach (self::mirroredProperties($tool->inputSchema) as $name => $path) { $error = $this->checkParam( - self::PARAM_HEADER_PREFIX.$definition['x-mcp-header'], + self::PARAM_HEADER_PREFIX.$name, $headers, - \array_key_exists($property, $arguments) ? $arguments[$property] : null, + self::valueAt($arguments, $path), ); if (null !== $error) { @@ -175,6 +166,70 @@ private function checkParams(string $method, ?array $params, array $headers): ?s return null; } + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count: the chain from the root must + * be `properties` keys the whole way. A chain through `items`, a + * composition keyword, `if`/`then`/`else` or a `$ref` is not extractable + * without evaluating the instance, so the specification puts an annotation + * there out of bounds — and this walk simply never reaches one. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + public static function mirroredProperties(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::mirroredProperties($definition, $here)]; + } + + return $found; + } + + /** + * Reads the instance value at an exact property path, or null when the path + * is not present — which the specification reads as "no header expected". + * + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } + /** * @param array $headers */ @@ -204,10 +259,23 @@ private function checkParam(string $headerName, array $headers, mixed $argument) default => null, }; + // A non-scalar cannot be mirrored at all, so the annotation on it is + // the tool definition's problem rather than this request's. if (null === $expected) { return null; } + // Numerically for numbers, so "42.0" and "42" agree — the spec asks for + // this, and a client's JSON writer is free to pick either. Gated on the + // argument's actual type (not is_numeric, which a numeric-looking + // string like "042" would also satisfy) and a decimal header (not + // "4e1"), so a string argument keeps its exact-match comparison. + if ((\is_int($argument) || \is_float($argument)) && 1 === preg_match('/^-?\d+(?:\.\d+)?$/', $decoded)) { + return (float) $decoded === (float) $argument + ? null + : \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); + } + if ($decoded !== $expected) { return \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); } diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index f0081d4d..43b4cc0f 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -11,6 +11,9 @@ namespace Mcp\Tests\Unit\Server\Stateless; +use Mcp\Capability\Registry; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Tool; use Mcp\Server\Stateless\StandardHeaderValidator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -173,6 +176,148 @@ public static function nameSources(): iterable yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null]; } + #[TestDox('an annotation on a nested property is found through the properties chain')] + public function testNestedMirroredPropertyIsFound(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'target' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + ], + ], + 'top' => ['type' => 'string', 'x-mcp-header' => 'Top'], + ], + ]; + + $this->assertSame( + ['Region' => ['target', 'region'], 'Top' => ['top']], + StandardHeaderValidator::mirroredProperties($schema), + ); + } + + #[TestDox('an annotation the chain cannot reach statically is not mirrored')] + #[DataProvider('unreachableAnnotations')] + public function testUnreachableAnnotationsAreNotMirrored(array $properties): void + { + $this->assertSame([], StandardHeaderValidator::mirroredProperties([ + 'type' => 'object', + 'properties' => $properties, + ])); + } + + /** + * @return iterable}> + */ + public static function unreachableAnnotations(): iterable + { + yield 'under items' => [['a' => ['type' => 'array', 'items' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + yield 'under anyOf' => [['a' => ['anyOf' => [['type' => 'string', 'x-mcp-header' => 'X']]]]]; + yield 'under if' => [['a' => ['if' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + } + + #[TestDox('an integer is compared numerically, so 42 and 42.0 agree')] + public function testIntegerParamsCompareNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42.0'], + )); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '43'], + )); + } + + #[TestDox('a numeric-looking string argument keeps its exact-match comparison')] + public function testNumericLookingStringArgumentIsNotComparedNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42'], + )); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '042'], + )); + } + + #[TestDox('a scientific-notation header is not accepted as a decimal number')] + public function testScientificNotationHeaderIsRejected(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 40]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '4e1'], + )); + } + + #[TestDox('a nested mirrored argument is read at its exact path')] + public function testNestedMirroredArgumentIsChecked(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Region' => 'us-west1'], + )); + + $this->assertStringContainsString('Missing required Mcp-Param-Region', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + + // Absent at that path, so no header is expected. + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + } + + private static function registryWithMirroredTool(): RegistryInterface + { + $registry = new Registry(); + $registry->registerTool( + new Tool( + name: 'mirrored', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'required' => null, + ], + description: 'x', + annotations: null, + ), + static fn (): string => 'ok', + ); + + return $registry; + } + #[TestDox('a plain header value decodes to itself')] public function testPlainValuePassesThroughDecode(): void {