* [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.
321 lines
12 KiB
PHP
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']);
|
|
}
|
|
}
|