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
91 changes: 91 additions & 0 deletions src/Schema/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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 = $this->checkHeaderAnnotations()) {
throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason));
}
}

/**
* 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;
* 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.
*
* @return string|null the reason it is invalid, or null when every annotation is well-formed
*/
private function checkHeaderAnnotations(): ?string
{
$seen = [];

foreach ($this->headerAnnotations($this->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<string, mixed> $schema
*
* @return list<array{string, ?string, string}>
*/
private 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, ...$this->headerAnnotations($definition, $path)];
}

return $found;
}

/**
Expand Down
160 changes: 160 additions & 0 deletions tests/Unit/Schema/ToolHeaderAnnotationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?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\Tests\Unit\Schema;

use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Tool;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

class ToolHeaderAnnotationTest extends TestCase
{
/**
* @param array<string, mixed> $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, array{array<string, mixed>, 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<string, mixed> $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);
}
}