[Schema] Refuse an invalid x-mcp-header annotation at definition time (#441)

* [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.

* Clarify x-mcp-header validation only reaches properties

* Make checkHeaderAnnotations() a private instance method

* Make headerAnnotations() a private instance method

* Apply suggestion from @chr-hertel
This commit is contained in:
Christopher Hertel 2026-08-18 20:16:35 +02:00 • committed by GitHub
commit 605df8771f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

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