php-sdk/tests/Unit/Server/Stateless/StatelessProtocolTest.php
Christopher Hertel 5e73683079
[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.
2026-08-18 23:09:34 +02:00

321 lines
12 KiB
PHP

<?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']);
}
}