[Server] Close the conformance gaps in the modern lifecycle (#447)

* [Server] Let the client gateway see modern-era capabilities

The six supports*() probes read session state only InitializeHandler wrote,
so every request served statelessly reported no client capabilities at all —
a tool guarding on them took the unsupported branch without a signal.
StatelessProtocol now writes the request's declaration under the same keys.

* [Server] Reject the RPCs 2026-07-28 removed

resources/subscribe and resources/unsubscribe were still dispatched and
answered 200 OK, recording subscriptions the modern era never reads. They
join initialize, ping and logging/setLevel in the era guard; the handlers
stay registered because the handshake era still serves them.

* [Server] Decode a wrapped Mcp-Name before comparing it

The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI
or tool name outside the header-safe ASCII set — which the spec explicitly
tells clients to wrap — was compared encoded and refused with -32020.

* [Server] Require the MCP-Protocol-Version header

It was only checked for contradicting the body, so omitting it entirely
passed — leaving the value intermediaries route on unenforced. Tied to the
header validator's presence, since that is what a header-bearing transport
installs and stdio carries its metadata inline.

* [Server] Read the modern-era request body whole

A single read() takes whatever the stream cares to give — 64 bytes from a
chunked transfer is legal — so an oversized-but-valid POST came back as a
parse error. Shares the legacy transport's incremental read as a trait.

* [Schema][Server] Answer a notification with a status, not a response

A body with no id was dispatched as a request and came back as a JSON-RPC
error carrying "id": "" — an id nobody issued, and on a path a client uses
to tell a modern server from a legacy one. Notifications now get 202 and no
body, and Error omits an id it could not read instead of emptying it.

* [Server] Stop emitting the error codes 2026-07-28 reserved

resources/read answered -32002, which this revision forbids; prompts/get
and completion/complete answered it for an unknown *name*, which it never
meant; tools/call answered -32601, which belongs to an unknown method.
Only the resources/read code is revision-gated — older peers expect -32002
there, and nowhere else. Also routes the -32021 capability exception out of
the prompt and resource handlers, which were swallowing it into -32603.
This commit is contained in:
Christopher Hertel 2026-08-18 23:09:34 +02:00 • committed by GitHub
commit 5e73683079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 855 additions and 106 deletions

View file

@ -183,7 +183,7 @@ class CallToolHandlerTest extends TestCase
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::METHOD_NOT_FOUND, $response->code);
$this->assertEquals(Error::INVALID_PARAMS, $response->code);
}
public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void

View file

@ -251,7 +251,7 @@ class GetPromptHandlerTest extends TestCase
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code);
$this->assertEquals(Error::INVALID_PARAMS, $response->code);
$this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message);
}

View file

@ -101,6 +101,52 @@ class StandardHeaderValidatorTest extends TestCase
));
}
#[TestDox('a Base64-wrapped Mcp-Name is decoded before it is compared')]
#[DataProvider('wrappedNames')]
public function testWrappedNameHeaderIsDecoded(string $method, string $member, string $subject): void
{
$this->assertNull($this->validator->validate(
$method,
[$member => $subject],
[
'Mcp-Method' => $method,
'Mcp-Name' => '=?base64?'.base64_encode($subject).'?=',
],
));
}
/**
* @return iterable<string, array{string, string, string}>
*/
public static function wrappedNames(): iterable
{
yield 'non-ASCII tool name' => ['tools/call', 'name', 'grüße_welt'];
yield 'CJK prompt name' => ['prompts/get', 'name', '天気予報'];
yield 'resource URI with a non-ASCII path' => ['resources/read', 'uri', 'file:///projects/münchen/config.json'];
yield 'value padded with spaces' => ['tools/call', 'name', ' padded '];
yield 'value matching the sentinel pattern' => ['tools/call', 'name', '=?base64?literal?='];
}
#[TestDox('a wrapped Mcp-Name that disagrees with the body is still rejected')]
public function testWrappedNameHeaderStillHasToMatch(): void
{
$this->assertStringContainsString('does not match', (string) $this->validator->validate(
'tools/call',
['name' => 'grüße_welt'],
['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?'.base64_encode('etwas_anderes').'?='],
));
}
#[TestDox('a malformed Base64 wrapper on Mcp-Name is refused, not compared raw')]
public function testMalformedWrappedNameHeaderIsRefused(): void
{
$this->assertStringContainsString('well-formed Base64', (string) $this->validator->validate(
'tools/call',
['name' => 'do_thing'],
['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?SGVsbG8!?='],
));
}
#[TestDox('a method that carries no name does not require the header')]
public function testNamelessMethodNeedsNoNameHeader(): void
{

View file

@ -0,0 +1,321 @@
<?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\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server;
use Mcp\Server\RequestContext;
use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Stateless\StatelessProtocol;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class StatelessProtocolTest extends TestCase
{
/**
* @param array<string, mixed> $capabilities
*/
private static function protocol(array $capabilities = []): StatelessProtocol
{
return Server::builder()
->setServerInfo('test-server', '1.0.0')
->addTool(static fn (): string => 'ok', name: 'plain_tool', description: 'Returns a fixed string')
->addTool(
static function (RequestContext $context): string {
$gateway = $context->getClientGateway();
return implode(',', array_keys(array_filter([
'roots' => $gateway->supportsRoots(),
'sampling' => $gateway->supportsSampling(),
'sampling.tools' => $gateway->supportsSamplingTools(),
'elicitation' => $gateway->supportsElicitation(),
'elicitation.url' => $gateway->supportsElicitationUrl(),
]))) ?: 'none';
},
name: 'probe_capabilities',
description: 'Reports which client capabilities the gateway can see',
)
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
->buildStateless([ProtocolVersion::V2026_07_28]);
}
/**
* @param array<string, mixed> $params
* @param array<string, string> $extraHeaders
*
* @return array<string, mixed>
*/
private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array
{
$params['_meta'] = [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
];
return self::callWithHeaders($protocol, $method, $params, [
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
'Mcp-Method' => $method,
...$extraHeaders,
]);
}
/**
* The header-exact variant: nothing is filled in, so a test can leave a
* required header out.
*
* @param array<string, mixed> $params
* @param array<string, string> $headers
*
* @return array<string, mixed>
*/
private static function callWithHeaders(
StatelessProtocol $protocol,
string $method,
array $params = [],
array $headers = [],
?string $metaVersion = null,
): array {
$params['_meta'] ??= [
RequestMeta::PROTOCOL_VERSION => $metaVersion ?? ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
];
$result = $protocol->handle(
json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR),
$headers,
);
return [
'status' => $result->httpStatus,
'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR),
];
}
#[TestDox('the gateway sees the capabilities this request declared, not an empty session')]
public function testClientCapabilitiesReachTheGateway(): void
{
$answer = self::call(
self::protocol(),
'tools/call',
['name' => 'probe_capabilities', 'arguments' => []],
['Mcp-Name' => 'probe_capabilities'],
['roots' => new \stdClass(), 'elicitation' => ['url' => new \stdClass()], 'sampling' => ['tools' => new \stdClass()]],
);
$this->assertSame(200, $answer['status']);
$this->assertSame(
'roots,sampling,sampling.tools,elicitation,elicitation.url',
$answer['body']['result']['content'][0]['text'],
);
}
#[TestDox('a client declaring nothing is reported as declaring nothing')]
public function testEmptyCapabilitiesReportNone(): void
{
$answer = self::call(
self::protocol(),
'tools/call',
['name' => 'probe_capabilities', 'arguments' => []],
['Mcp-Name' => 'probe_capabilities'],
);
$this->assertSame('none', $answer['body']['result']['content'][0]['text']);
}
/**
* @return iterable<string, array{string, array<string, mixed>}>
*/
public static function removedMethods(): iterable
{
yield 'initialize' => ['initialize', []];
yield 'ping' => ['ping', []];
yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'info']];
yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'test://static']];
yield 'resources/unsubscribe' => ['resources/unsubscribe', ['uri' => 'test://static']];
}
/**
* @param array<string, mixed> $params
*/
#[DataProvider('removedMethods')]
#[TestDox('a method this revision removed is answered as unknown')]
public function testRemovedMethodsAreUnknown(string $method, array $params): void
{
$answer = self::call(self::protocol(), $method, $params);
$this->assertSame(404, $answer['status']);
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
}
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
public function testResourceNotFoundUsesInvalidParams(): void
{
$answer = self::call(
self::protocol(),
'resources/read',
['uri' => 'test://absent'],
['Mcp-Name' => 'test://absent'],
);
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
$this->assertSame('test://absent', $answer['body']['error']['data']['uri']);
}
#[TestDox('an unknown tool is a bad parameter, not an unknown method')]
public function testUnknownToolUsesInvalidParams(): void
{
$answer = self::call(
self::protocol(),
'tools/call',
['name' => 'no_such_tool', 'arguments' => []],
['Mcp-Name' => 'no_such_tool'],
);
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
}
#[TestDox('an unknown prompt is a bad parameter too')]
public function testUnknownPromptUsesInvalidParams(): void
{
$answer = self::call(
self::protocol(),
'prompts/get',
['name' => 'no_such_prompt', 'arguments' => []],
['Mcp-Name' => 'no_such_prompt'],
);
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
}
#[TestDox('this revision never emits the codes it reserved')]
public function testReservedCodesAreNeverEmitted(): void
{
$protocol = self::protocol();
$answers = [
self::call($protocol, 'resources/read', ['uri' => 'test://absent'], ['Mcp-Name' => 'test://absent']),
self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']),
self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']),
];
foreach ($answers as $answer) {
// -32002 (resource not found) and -32042 (url elicitation required)
// are reserved by earlier revisions and never reused.
$this->assertNotContains($answer['body']['error']['code'], [-32002, -32042]);
}
}
#[TestDox('a notification is acknowledged with no body, never answered')]
public function testNotificationIsAcknowledged(): void
{
$result = self::protocol()->handle(
json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/something', 'params' => []], \JSON_THROW_ON_ERROR),
['Mcp-Method' => 'notifications/something'],
);
$this->assertTrue($result->isEmpty());
$this->assertSame(202, $result->httpStatus);
}
#[TestDox('a notification for a removed method is refused with no body')]
public function testRemovedNotificationIsRefused(): void
{
$result = self::protocol()->handle(
json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], \JSON_THROW_ON_ERROR),
['Mcp-Method' => 'notifications/initialized'],
);
$this->assertTrue($result->isEmpty());
$this->assertSame(400, $result->httpStatus);
}
#[TestDox('an error for a request whose id could not be read omits the id')]
public function testUnreadableIdIsOmittedRatherThanEmptied(): void
{
$result = self::protocol()->handle('{"jsonrpc":"2.0","id":{"bad":1},"method":"tools/list"}', []);
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
$this->assertArrayNotHasKey('id', $body);
$this->assertSame(Error::INVALID_REQUEST, $body['error']['code']);
}
#[TestDox('an error for a readable id still echoes it')]
public function testReadableIdIsEchoed(): void
{
$answer = self::callWithHeaders(self::protocol(), 'tools/list', [], ['Mcp-Method' => 'tools/list']);
$this->assertSame(1, $answer['body']['id']);
}
#[TestDox('a POST without the MCP-Protocol-Version header is refused')]
public function testMissingProtocolVersionHeaderIsRefused(): void
{
$answer = self::callWithHeaders(
self::protocol(),
'tools/list',
[],
['Mcp-Method' => 'tools/list'],
);
$this->assertSame(400, $answer['status']);
$this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']);
$this->assertStringContainsString('MCP-Protocol-Version', $answer['body']['error']['message']);
}
#[TestDox('a header contradicting the _meta version outranks an unsupported version')]
public function testContradictingProtocolVersionHeaderIsRefused(): void
{
$answer = self::callWithHeaders(
self::protocol(),
'tools/list',
[],
['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '2025-11-25'],
);
$this->assertSame(400, $answer['status']);
$this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']);
}
#[TestDox('an unsupported version carries the supported set the client can retry from')]
public function testUnsupportedProtocolVersionCarriesSupportedSet(): void
{
$answer = self::callWithHeaders(
self::protocol(),
'tools/list',
[],
['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '1900-01-01'],
'1900-01-01',
);
$this->assertSame(400, $answer['status']);
$this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answer['body']['error']['code']);
$this->assertSame('1900-01-01', $answer['body']['error']['data']['requested']);
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']);
}
#[TestDox('elicitation without a named mode reports form, not url')]
public function testElicitationDefaultsToFormMode(): void
{
$answer = self::call(
self::protocol(),
'tools/call',
['name' => 'probe_capabilities', 'arguments' => []],
['Mcp-Name' => 'probe_capabilities'],
['elicitation' => new \stdClass()],
);
$this->assertSame('elicitation', $answer['body']['result']['content'][0]['text']);
}
}

View file

@ -0,0 +1,110 @@
<?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\Transport\Fixture;
use Psr\Http\Message\StreamInterface;
/**
* A non-seekable stream that hands back at most $chunkSize bytes per read.
*
* Models what PSR-7 actually promises — *up to* the requested length — which a
* stream over `php://input` or a chunked transfer delivers routinely, and which
* a single `read($cap)` therefore truncates.
*/
final class ShortReadStream implements StreamInterface
{
private int $offset = 0;
public function __construct(
private readonly string $contents,
private readonly int $chunkSize = 64,
private readonly bool $advertiseSize = true,
) {
}
public function read(int $length): string
{
$chunk = substr($this->contents, $this->offset, min($length, $this->chunkSize));
$this->offset += \strlen($chunk);
return $chunk;
}
public function eof(): bool
{
return $this->offset >= \strlen($this->contents);
}
public function getSize(): ?int
{
return $this->advertiseSize ? \strlen($this->contents) : null;
}
public function isSeekable(): bool
{
return false;
}
public function __toString(): string
{
return $this->contents;
}
public function getContents(): string
{
return substr($this->contents, $this->offset);
}
public function close(): void
{
}
public function detach()
{
return null;
}
public function tell(): int
{
return $this->offset;
}
public function seek(int $offset, int $whence = \SEEK_SET): void
{
throw new \RuntimeException('Not seekable.');
}
public function rewind(): void
{
throw new \RuntimeException('Not seekable.');
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new \RuntimeException('Not writable.');
}
public function isReadable(): bool
{
return true;
}
public function getMetadata(?string $key = null)
{
return null === $key ? [] : null;
}
}

View file

@ -0,0 +1,140 @@
<?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\Transport;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server;
use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Transport\StatelessHttpTransport;
use Mcp\Tests\Unit\Server\Transport\Fixture\ShortReadStream;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
final class StatelessHttpTransportTest extends TestCase
{
private Psr17Factory $factory;
protected function setUp(): void
{
$this->factory = new Psr17Factory();
}
private function protocol(): StatelessProtocol
{
return Server::builder()
->setServerInfo('test-server', '1.0.0')
->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument')
->buildStateless([ProtocolVersion::V2026_07_28]);
}
/**
* A `tools/call` body padded so it comfortably exceeds one 8 KiB read.
*/
private function callBody(int $padding = 0): string
{
return json_encode([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'echo_tool',
'arguments' => ['text' => str_repeat('x', $padding)],
'_meta' => [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
],
],
], \JSON_THROW_ON_ERROR);
}
private function post(string $body): ServerRequestInterface
{
return $this->factory
->createServerRequest('POST', 'http://localhost/mcp')
->withHeader('Host', 'localhost')
->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value)
->withHeader('Mcp-Method', 'tools/call')
->withHeader('Mcp-Name', 'echo_tool')
->withBody($this->factory->createStream($body));
}
#[TestDox('a body arriving in short reads is assembled whole, not truncated')]
public function testShortReadsDoNotTruncateTheBody(): void
{
$body = $this->callBody(20_000);
// PSR-7 promises only *up to* the requested length; a stream over
// php://input or a chunked transfer routinely returns less.
$request = $this->post('')->withBody(new ShortReadStream($body, 64));
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request);
$this->assertSame(200, $response->getStatusCode());
$decoded = json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR);
$this->assertSame('echo:'.str_repeat('x', 20_000), $decoded['result']['content'][0]['text']);
}
#[TestDox('a body over the cap is refused with 413')]
public function testOversizedBodyIsRefused(): void
{
$request = $this->post($this->callBody(2048));
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request);
$this->assertSame(413, $response->getStatusCode());
}
#[TestDox('an oversized body is refused even when its size is not advertised')]
public function testOversizedUnsizedBodyIsRefused(): void
{
$request = $this->post('')->withBody(new ShortReadStream($this->callBody(2048), 64, advertiseSize: false));
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request);
$this->assertSame(413, $response->getStatusCode());
}
#[TestDox('a notification POST is acknowledged with 202 and no body')]
public function testNotificationPostIsAccepted(): void
{
$body = json_encode([
'jsonrpc' => '2.0',
'method' => 'notifications/something',
'params' => [],
], \JSON_THROW_ON_ERROR);
$request = $this->post($body)->withHeader('Mcp-Method', 'notifications/something');
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request);
$this->assertSame(202, $response->getStatusCode());
$this->assertSame('', (string) $response->getBody());
}
#[TestDox('GET and DELETE are refused: there is no session to address')]
public function testOnlyPostIsAccepted(): void
{
$transport = new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory);
foreach (['GET', 'DELETE', 'PUT'] as $method) {
$request = $this->factory
->createServerRequest($method, 'http://localhost/mcp')
->withHeader('Host', 'localhost');
$this->assertSame(405, $transport->handle($request)->getStatusCode(), $method);
}
}
}