Skip to content
Merged
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
92 changes: 80 additions & 12 deletions src/Server/Stateless/StandardHeaderValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string, mixed> $schema
* @param list<string> $path
*
* @return array<string, list<string>>
*/
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<string, mixed> $arguments
* @param list<string> $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<string, string> $headers
*/
Expand Down Expand Up @@ -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);
}
Expand Down
145 changes: 145 additions & 0 deletions tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{array<string, mixed>}>
*/
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
{
Expand Down