[Server][Tests] Validate the standard MCP request headers (SEP-2243) (#445)
The revision puts the method, the subject name and the mirrored arguments in headers so an intermediary can route and cache without parsing the body. A header that contradicts the body it travels with therefore has to be refused, not ignored: StandardHeaderValidator compares each against the request and answers -32020 on a mismatch. Set with Builder::setHeaderValidator(); a transport with no header layer is unaffected.
This commit is contained in:
parent
3d596ca75b
commit
de35ca40bf
7 changed files with 478 additions and 14 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored.
|
||||
* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them.
|
||||
* Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response.
|
||||
* [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore;
|
|||
use Mcp\Server\Session\SessionManager;
|
||||
use Mcp\Server\Session\SessionManagerInterface;
|
||||
use Mcp\Server\Session\SessionStoreInterface;
|
||||
use Mcp\Server\Stateless\StandardHeaderValidator;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
|
@ -228,6 +229,8 @@ final class Builder
|
|||
|
||||
private bool $lazyLoading = true;
|
||||
|
||||
private bool $headerValidation = true;
|
||||
|
||||
/**
|
||||
* Sets the server's identity. Required.
|
||||
*
|
||||
|
|
@ -389,6 +392,21 @@ final class Builder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether {@see self::buildStateless()} checks the standard MCP
|
||||
* request headers (SEP-2243) against the JSON-RPC body. On by default.
|
||||
*
|
||||
* Disable for a `StatelessProtocol` served by a transport with no header
|
||||
* layer — the validator would otherwise reject every request for
|
||||
* carrying none of the standard headers.
|
||||
*/
|
||||
public function setHeaderValidator(bool $headerValidation = true): self
|
||||
{
|
||||
$this->headerValidation = $headerValidation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a PSR-3 logger instance. Defaults to NullLogger.
|
||||
*/
|
||||
|
|
@ -717,6 +735,7 @@ final class Builder
|
|||
configuration: $parts['configuration'],
|
||||
supportedVersions: $supportedVersions,
|
||||
logger: $parts['logger'],
|
||||
headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -728,6 +747,7 @@ final class Builder
|
|||
* configuration: Configuration,
|
||||
* messageFactory: MessageFactory,
|
||||
* sessionManager: SessionManagerInterface,
|
||||
* registry: RegistryInterface,
|
||||
* requestHandlers: list<RequestHandlerInterface<mixed>>,
|
||||
* notificationHandlers: list<NotificationHandlerInterface>,
|
||||
* }
|
||||
|
|
@ -824,6 +844,7 @@ final class Builder
|
|||
|
||||
return [
|
||||
'logger' => $logger,
|
||||
'registry' => $registry,
|
||||
'configuration' => $configuration,
|
||||
'messageFactory' => $messageFactory,
|
||||
'sessionManager' => $sessionManager,
|
||||
|
|
|
|||
265
src/Server/Stateless/StandardHeaderValidator.php
Normal file
265
src/Server/Stateless/StandardHeaderValidator.php
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<?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\Server\Stateless;
|
||||
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
|
||||
/**
|
||||
* Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243).
|
||||
*
|
||||
* The headers exist so an intermediary — a gateway, a proxy, a rate limiter —
|
||||
* can route and police MCP traffic without parsing the body. That only works
|
||||
* if the two cannot disagree, so a server that *does* read the body is
|
||||
* required to confirm they match and to reject the request when they do not.
|
||||
* Otherwise a caller could show a gateway one request and the server another.
|
||||
*
|
||||
* Failures are all one kind: HTTP 400 with JSON-RPC `-32020`.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class StandardHeaderValidator
|
||||
{
|
||||
public const METHOD_HEADER = 'Mcp-Method';
|
||||
public const NAME_HEADER = 'Mcp-Name';
|
||||
public const PARAM_HEADER_PREFIX = 'Mcp-Param-';
|
||||
|
||||
/** Wrapper marking a header value as Base64 of its UTF-8 representation. */
|
||||
private const BASE64_PREFIX = '=?base64?';
|
||||
private const BASE64_SUFFIX = '?=';
|
||||
|
||||
public function __construct(
|
||||
private readonly ?RegistryInterface $registry = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $params
|
||||
* @param array<string, string> $headers
|
||||
*
|
||||
* @return string|null the reason to reject, or null when the request is consistent
|
||||
*/
|
||||
public function validate(string $method, ?array $params, array $headers): ?string
|
||||
{
|
||||
return $this->checkMethod($method, $headers)
|
||||
?? $this->checkName($method, $params, $headers)
|
||||
?? $this->checkParams($method, $params, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function checkMethod(string $method, array $headers): ?string
|
||||
{
|
||||
$declared = $this->header($headers, self::METHOD_HEADER);
|
||||
|
||||
if (null === $declared) {
|
||||
return \sprintf('Missing required %s header (body method is "%s").', self::METHOD_HEADER, $method);
|
||||
}
|
||||
|
||||
if ($declared !== $method) {
|
||||
return \sprintf('%s header "%s" does not match the body method "%s".', self::METHOD_HEADER, $declared, $method);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Mcp-Name` names whatever the request acts on, which is a different
|
||||
* member per method. When the body carries one, the header must repeat it;
|
||||
* when it does not, the header has nothing to agree with and the server
|
||||
* must not demand one.
|
||||
*
|
||||
* @param array<string, mixed>|null $params
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function checkName(string $method, ?array $params, array $headers): ?string
|
||||
{
|
||||
$expected = self::nameFor($method, $params);
|
||||
$declared = $this->header($headers, self::NAME_HEADER);
|
||||
|
||||
if (null === $expected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $declared) {
|
||||
return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected);
|
||||
}
|
||||
|
||||
if ($declared !== $expected) {
|
||||
return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $declared, $expected);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject of a request, per method. Anything not listed carries no
|
||||
* name, and so is exempt from the header entirely.
|
||||
*
|
||||
* @param array<string, mixed>|null $params
|
||||
*/
|
||||
public static function nameFor(string $method, ?array $params): ?string
|
||||
{
|
||||
$value = match ($method) {
|
||||
'tools/call', 'prompts/get' => $params['name'] ?? null,
|
||||
'resources/read' => $params['uri'] ?? null,
|
||||
'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return \is_string($value) ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool arguments a tool marked with `x-mcp-header` are mirrored into
|
||||
* `Mcp-Param-*` headers, and must survive the round trip unchanged.
|
||||
*
|
||||
* Only headers the *tool* declares are checked. An unrecognized
|
||||
* `Mcp-Param-*` belongs to somebody else in the chain — the spec has
|
||||
* intermediaries forward what they do not understand — so rejecting it
|
||||
* would break traffic this server has no stake in.
|
||||
*
|
||||
* @param array<string, mixed>|null $params
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function checkParams(string $method, ?array $params, array $headers): ?string
|
||||
{
|
||||
if ('tools/call' !== $method || null === $this->registry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$toolName = $params['name'] ?? null;
|
||||
if (!\is_string($toolName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$tool = $this->registry->getTool($toolName)->tool;
|
||||
} catch (ToolNotFoundException) {
|
||||
// Not this validator's error to report: the handler will answer
|
||||
// with a proper tool-not-found rather than a header complaint.
|
||||
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;
|
||||
}
|
||||
|
||||
$error = $this->checkParam(
|
||||
self::PARAM_HEADER_PREFIX.$definition['x-mcp-header'],
|
||||
$headers,
|
||||
\array_key_exists($property, $arguments) ? $arguments[$property] : null,
|
||||
);
|
||||
|
||||
if (null !== $error) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function checkParam(string $headerName, array $headers, mixed $argument): ?string
|
||||
{
|
||||
$declared = $this->header($headers, $headerName);
|
||||
|
||||
// A null or omitted argument means the client omits the header, and the
|
||||
// server must not expect one.
|
||||
if (null === $argument) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $declared) {
|
||||
return \sprintf('Missing required %s header (body carries the mirrored argument).', $headerName);
|
||||
}
|
||||
|
||||
$decoded = self::decode($declared);
|
||||
|
||||
if (null === $decoded) {
|
||||
return \sprintf('%s header is not a well-formed Base64 wrapper.', $headerName);
|
||||
}
|
||||
|
||||
// Numbers travel as their decimal string, booleans as "true"/"false" —
|
||||
// so the comparison is between the decoded header and the argument
|
||||
// rendered the same way, not between PHP values.
|
||||
$expected = match (true) {
|
||||
\is_bool($argument) => $argument ? 'true' : 'false',
|
||||
\is_scalar($argument) => (string) $argument,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $expected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($decoded !== $expected) {
|
||||
return \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a `=?base64?…?=` value, or returns a plain value unchanged.
|
||||
*
|
||||
* Decoding is strict. PHP's decoder is happy to accept mispadded or
|
||||
* out-of-alphabet input and hand back plausible-looking bytes, which would
|
||||
* turn a corrupted header into a silent mismatch — or worse, a silent
|
||||
* match. Returns null when the wrapper is present but its contents are not
|
||||
* valid Base64.
|
||||
*/
|
||||
public static function decode(string $value): ?string
|
||||
{
|
||||
if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX));
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
|
||||
if (false === $decoded || base64_encode($decoded) !== $encoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header names compare case-insensitively, and values carry no meaning in
|
||||
* the optional whitespace around them (RFC 9110 §5.5).
|
||||
*
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function header(array $headers, string $name): ?string
|
||||
{
|
||||
foreach ($headers as $key => $value) {
|
||||
if (0 === strcasecmp($key, $name)) {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ final class StatelessProtocol
|
|||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
private readonly float $subscriptionLifetime = 30.0,
|
||||
?WireCodecInterface $codec = null,
|
||||
private readonly ?StandardHeaderValidator $headerValidator = null,
|
||||
) {
|
||||
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
|
||||
}
|
||||
|
|
@ -140,6 +141,14 @@ final class StatelessProtocol
|
|||
return $versionError;
|
||||
}
|
||||
|
||||
// After the version is settled, because a peer on the wrong revision
|
||||
// has a more fundamental problem than a header that disagrees with its
|
||||
// body — and telling it about the headers first would send it fixing
|
||||
// the wrong thing.
|
||||
if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
|
||||
return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400);
|
||||
}
|
||||
|
||||
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
|
||||
// Both answer with a single response tied to this request's id,
|
||||
// unlike a genuine notification — so unlike the general dispatch
|
||||
|
|
|
|||
|
|
@ -13,21 +13,10 @@
|
|||
# make conformance-draft-server
|
||||
#
|
||||
# Absent by design, because they pass in full: server-stateless (the SEP-2575
|
||||
# lifecycle), caching (SEP-2549 hints), and every wire-schema-valid check bar
|
||||
# one noted below.
|
||||
# lifecycle), caching (SEP-2549 hints), both SEP-2243 header-validation
|
||||
# scenarios, and every wire-schema-valid check bar one noted below.
|
||||
|
||||
server:
|
||||
# --- SEP-2243: standard HTTP headers ---------------------------------------
|
||||
# Mcp-Method / Mcp-Name validation and the X-MCP-* parameter rules are not
|
||||
# implemented. Self-contained and the largest remaining block; a good next
|
||||
# chunk after MRTR.
|
||||
- http-header-validation:sep-2243-server-reject-invalid-headers
|
||||
- http-custom-header-server-validation:sep-2243-server-no-xmcp-tool
|
||||
- http-custom-header-server-validation:sep-2243-server-decode-base64
|
||||
- http-custom-header-server-validation:sep-2243-server-validate-param-match
|
||||
- http-custom-header-server-validation:sep-2243-server-reject-invalid-param-chars
|
||||
- http-custom-header-server-validation:sep-2243-server-reject-param-mismatch
|
||||
|
||||
# --- SEP-2322: the MRTR retry half -----------------------------------------
|
||||
# InputRequiredResult exists and tools/call can answer with one — that is what
|
||||
# makes server-stateless green. What is missing is the second round trip: the
|
||||
|
|
@ -49,7 +38,6 @@ server:
|
|||
# keyword), so the run summary counts them as passed — but the baseline
|
||||
# evaluator still wants them declared, and declaring them is right: each is a
|
||||
# SHOULD the SDK does not yet honour.
|
||||
- http-header-validation:sep-2243-server-reject-error-code
|
||||
- input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params
|
||||
- input-required-result-missing-input-response:sep-2322-missing-response-rerequests
|
||||
- sep-2164-resource-not-found:sep-2164-data-uri
|
||||
|
|
|
|||
|
|
@ -93,6 +93,21 @@ $protocol = Server::builder()
|
|||
name: 'test_logging_tool',
|
||||
description: 'Emits a server-side log message while returning normally',
|
||||
)
|
||||
// Mirrors its arguments into Mcp-Param-* headers (SEP-2243), so the header/
|
||||
// body agreement rules have something to be checked against.
|
||||
->addTool(
|
||||
static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries),
|
||||
name: 'test_custom_headers',
|
||||
description: 'Tests custom header mirroring via x-mcp-header',
|
||||
inputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
|
||||
'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'],
|
||||
],
|
||||
'required' => ['region'],
|
||||
],
|
||||
)
|
||||
// Resources
|
||||
->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing')
|
||||
->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing')
|
||||
|
|
|
|||
165
tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php
Normal file
165
tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?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\Server\Stateless;
|
||||
|
||||
use Mcp\Server\Stateless\StandardHeaderValidator;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StandardHeaderValidatorTest extends TestCase
|
||||
{
|
||||
private StandardHeaderValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// No registry: the Mcp-Param dimension needs tool definitions and is
|
||||
// covered by the conformance fixture, which has real ones. Everything
|
||||
// here is registry-independent.
|
||||
$this->validator = new StandardHeaderValidator();
|
||||
}
|
||||
|
||||
#[TestDox('a request whose headers agree with its body passes')]
|
||||
public function testConsistentRequestPasses(): void
|
||||
{
|
||||
$this->assertNull($this->validator->validate(
|
||||
'tools/call',
|
||||
['name' => 'do_thing', 'arguments' => []],
|
||||
['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'do_thing'],
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('a missing Mcp-Method is rejected')]
|
||||
public function testMissingMethodHeaderRejected(): void
|
||||
{
|
||||
$this->assertStringContainsString(
|
||||
'Mcp-Method',
|
||||
(string) $this->validator->validate('tools/list', null, []),
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('an Mcp-Method that disagrees with the body is rejected')]
|
||||
public function testMismatchedMethodHeaderRejected(): void
|
||||
{
|
||||
$this->assertStringContainsString(
|
||||
'does not match',
|
||||
(string) $this->validator->validate('tools/list', null, ['Mcp-Method' => 'prompts/list']),
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('header names compare case-insensitively')]
|
||||
#[DataProvider('headerCasings')]
|
||||
public function testHeaderNameCasingIsIgnored(string $name): void
|
||||
{
|
||||
$this->assertNull($this->validator->validate('tools/list', null, [$name => 'tools/list']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function headerCasings(): iterable
|
||||
{
|
||||
yield 'canonical' => ['Mcp-Method'];
|
||||
yield 'lowercase' => ['mcp-method'];
|
||||
yield 'uppercase' => ['MCP-METHOD'];
|
||||
yield 'mixed' => ['mCp-MeThOd'];
|
||||
}
|
||||
|
||||
#[TestDox('surrounding whitespace is not part of a header value (RFC 9110 §5.5)')]
|
||||
public function testOptionalWhitespaceIsTrimmed(): void
|
||||
{
|
||||
$this->assertNull($this->validator->validate(
|
||||
'tools/call',
|
||||
['name' => 'do_thing'],
|
||||
['Mcp-Method' => 'tools/call', 'Mcp-Name' => ' do_thing '],
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('Mcp-Name is required when the body carries a name, and rejected when it disagrees')]
|
||||
public function testNameHeaderMustMatchBody(): void
|
||||
{
|
||||
$body = ['name' => 'do_thing'];
|
||||
|
||||
$this->assertStringContainsString('Missing required Mcp-Name', (string) $this->validator->validate(
|
||||
'tools/call',
|
||||
$body,
|
||||
['Mcp-Method' => 'tools/call'],
|
||||
));
|
||||
|
||||
$this->assertStringContainsString('does not match', (string) $this->validator->validate(
|
||||
'tools/call',
|
||||
$body,
|
||||
['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'something_else'],
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('a method that carries no name does not require the header')]
|
||||
public function testNamelessMethodNeedsNoNameHeader(): void
|
||||
{
|
||||
$this->assertNull($this->validator->validate('tools/list', null, ['Mcp-Method' => 'tools/list']));
|
||||
}
|
||||
|
||||
#[TestDox('each method names its subject through its own params member')]
|
||||
#[DataProvider('nameSources')]
|
||||
public function testNameIsReadFromTheRightMember(string $method, array $params, ?string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, StandardHeaderValidator::nameFor($method, $params));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, array<string, mixed>, ?string}>
|
||||
*/
|
||||
public static function nameSources(): iterable
|
||||
{
|
||||
yield 'tools/call uses name' => ['tools/call', ['name' => 'a'], 'a'];
|
||||
yield 'prompts/get uses name' => ['prompts/get', ['name' => 'b'], 'b'];
|
||||
yield 'resources/read uses uri' => ['resources/read', ['uri' => 'test://c'], 'test://c'];
|
||||
yield 'tasks/get uses taskId' => ['tasks/get', ['taskId' => 'd'], 'd'];
|
||||
yield 'tools/list has no subject' => ['tools/list', [], null];
|
||||
// A non-string value is not a name: treating it as one would compare a
|
||||
// header against something that was never a header value.
|
||||
yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null];
|
||||
}
|
||||
|
||||
#[TestDox('a plain header value decodes to itself')]
|
||||
public function testPlainValuePassesThroughDecode(): void
|
||||
{
|
||||
$this->assertSame('Hello', StandardHeaderValidator::decode('Hello'));
|
||||
}
|
||||
|
||||
#[TestDox('a well-formed Base64 wrapper decodes to its contents')]
|
||||
public function testValidBase64Decodes(): void
|
||||
{
|
||||
$this->assertSame('Hello', StandardHeaderValidator::decode('=?base64?'.base64_encode('Hello').'?='));
|
||||
}
|
||||
|
||||
#[TestDox('malformed Base64 is refused rather than salvaged')]
|
||||
#[DataProvider('malformedBase64')]
|
||||
public function testMalformedBase64IsRefused(string $encoded): void
|
||||
{
|
||||
// PHP's decoder will happily return bytes for most of these. Accepting
|
||||
// them would let a corrupted header silently compare equal — or, worse,
|
||||
// silently unequal — instead of being reported as the transport-level
|
||||
// fault it is.
|
||||
$this->assertNull(StandardHeaderValidator::decode('=?base64?'.$encoded.'?='));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function malformedBase64(): iterable
|
||||
{
|
||||
yield 'missing padding' => ['SGVsbG8'];
|
||||
yield 'out-of-alphabet character' => ['SGVsbG8!'];
|
||||
yield 'stray whitespace' => ['SGVs bG8='];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue