traceparent, tracestate and baggage reach handlers through RequestContext and are echoed onto the notifications the request causes, so a span is not broken by the response stream. Passed through verbatim: what they mean is the tracing library's business, and no dependency is taken for it.
896 lines
36 KiB
PHP
896 lines
36 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\Exception\MissingRequiredClientCapabilityException;
|
|
use Mcp\Schema\ClientCapabilities;
|
|
use Mcp\Schema\Content\TextResourceContents;
|
|
use Mcp\Schema\Elicitation\ElicitationSchema;
|
|
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
|
use Mcp\Schema\Enum\CacheScope;
|
|
use Mcp\Schema\Enum\LoggingLevel;
|
|
use Mcp\Schema\Enum\ProtocolVersion;
|
|
use Mcp\Schema\JsonRpc\Error;
|
|
use Mcp\Schema\Notification\PromptListChangedNotification;
|
|
use Mcp\Schema\Notification\ResourceUpdatedNotification;
|
|
use Mcp\Schema\Notification\ToolListChangedNotification;
|
|
use Mcp\Schema\Request\ElicitRequest;
|
|
use Mcp\Schema\Result\InputRequiredResult;
|
|
use Mcp\Schema\Result\ReadResourceResult;
|
|
use Mcp\Schema\ServerCapabilities;
|
|
use Mcp\Server;
|
|
use Mcp\Server\RequestContext;
|
|
use Mcp\Server\Stateless\RequestMeta;
|
|
use Mcp\Server\Stateless\StatelessProtocol;
|
|
use Mcp\Server\Stateless\StatelessResult;
|
|
use Mcp\Server\Subscription\InMemoryNotificationBus;
|
|
use Mcp\Server\Wire\CachePolicy;
|
|
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',
|
|
)
|
|
->addTool(
|
|
static function (RequestContext $context): string {
|
|
$gateway = $context->getClientGateway();
|
|
$gateway->progress(0, 100, 'starting');
|
|
$gateway->progress(100, 100, 'done');
|
|
|
|
return 'progressed';
|
|
},
|
|
name: 'progress_tool',
|
|
description: 'Reports progress while it works',
|
|
)
|
|
->addTool(
|
|
static function (RequestContext $context): string {
|
|
$gateway = $context->getClientGateway();
|
|
foreach ([LoggingLevel::Debug, LoggingLevel::Info, LoggingLevel::Warning, LoggingLevel::Error] as $level) {
|
|
$gateway->log($level, $level->value.' message');
|
|
}
|
|
|
|
return 'logged';
|
|
},
|
|
name: 'logging_tool',
|
|
description: 'Emits one message at each of four levels',
|
|
)
|
|
->addTool(
|
|
static function (): never {
|
|
throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'needs sampling');
|
|
},
|
|
name: 'capability_tool',
|
|
description: 'Always reports a missing client capability',
|
|
)
|
|
->addTool(
|
|
static function (RequestContext $context): string {
|
|
// The pattern this revision replaced: kept as a fixture so
|
|
// the refusal has something to refuse.
|
|
$context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n']));
|
|
|
|
return 'unreachable';
|
|
},
|
|
name: 'elicits_directly',
|
|
description: 'Asks the client directly, which this revision forbids',
|
|
)
|
|
->addTool(
|
|
static fn (): InputRequiredResult => new InputRequiredResult([
|
|
'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'),
|
|
]),
|
|
name: 'asks_by_url',
|
|
description: 'Asks through a url-mode elicitation',
|
|
)
|
|
->addTool(
|
|
static function (RequestContext $context): InputRequiredResult {
|
|
$context->getClientGateway()->progress(0, 100, 'starting');
|
|
|
|
return new InputRequiredResult([
|
|
'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'),
|
|
]);
|
|
},
|
|
name: 'asks_by_url_after_progress',
|
|
description: 'Emits progress, then asks through a url-mode elicitation',
|
|
)
|
|
->addTool(
|
|
static function (RequestContext $context): string {
|
|
$pairs = [];
|
|
foreach ($context->getTraceContext() as $key => $value) {
|
|
$pairs[] = $key.'='.$value;
|
|
}
|
|
|
|
return implode(';', $pairs) ?: 'none';
|
|
},
|
|
name: 'probe_trace',
|
|
description: 'Reports the trace context it was called with',
|
|
)
|
|
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
|
|
->addResource(
|
|
static function (RequestContext $context): string|InputRequiredResult {
|
|
$input = $context->getInputContext();
|
|
|
|
if (null === $input || !$input->has('who')) {
|
|
return new InputRequiredResult([
|
|
'who' => new ElicitRequest('Who is asking?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])),
|
|
]);
|
|
}
|
|
|
|
return 'body for '.($input->response('who')['content']['n'] ?? '?');
|
|
},
|
|
'test://gated',
|
|
'gated',
|
|
'A resource that asks who is reading before it answers',
|
|
)
|
|
->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
|
|
{
|
|
// Merged, not replaced: a test may add its own `_meta` members.
|
|
$params['_meta'] = [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
|
|
...($params['_meta'] ?? []),
|
|
];
|
|
|
|
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']);
|
|
}
|
|
|
|
/**
|
|
* Drains a streaming result into the frames it would write.
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private static function frames(StatelessResult $result): array
|
|
{
|
|
$frames = [];
|
|
|
|
foreach (($result->frames)() as $frame) {
|
|
if (null !== $frame) {
|
|
$frames[] = $frame;
|
|
}
|
|
}
|
|
|
|
return $frames;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $meta
|
|
*/
|
|
private static function callStreaming(StatelessProtocol $protocol, string $tool, array $meta = []): StatelessResult
|
|
{
|
|
return $protocol->handle(json_encode([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 7,
|
|
'method' => 'tools/call',
|
|
'params' => [
|
|
'name' => $tool,
|
|
'arguments' => [],
|
|
'_meta' => [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
|
...$meta,
|
|
],
|
|
],
|
|
], \JSON_THROW_ON_ERROR), [
|
|
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
|
'Mcp-Method' => 'tools/call',
|
|
'Mcp-Name' => $tool,
|
|
'Accept' => 'application/json, text/event-stream',
|
|
]);
|
|
}
|
|
|
|
#[TestDox('progress notifications reach the client on the response stream')]
|
|
public function testProgressStreamsBeforeTheResponse(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']);
|
|
|
|
$this->assertTrue($result->isStream());
|
|
|
|
$frames = self::frames($result);
|
|
|
|
$this->assertCount(3, $frames);
|
|
$this->assertSame('notifications/progress', $frames[0]['method']);
|
|
$this->assertSame('tok-1', $frames[0]['params']['progressToken']);
|
|
$this->assertSame('notifications/progress', $frames[1]['method']);
|
|
$this->assertSame(7, $frames[2]['id']);
|
|
$this->assertSame('complete', $frames[2]['result']['resultType']);
|
|
}
|
|
|
|
#[TestDox('without a progress token the handler emits nothing and gets a plain response')]
|
|
public function testProgressWithoutATokenIsNotStreamed(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'progress_tool');
|
|
|
|
$this->assertFalse($result->isStream());
|
|
$this->assertSame(200, $result->httpStatus);
|
|
}
|
|
|
|
#[TestDox('a client that will not read a stream gets its notifications dropped, not a stream')]
|
|
public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void
|
|
{
|
|
$result = self::protocol()->handle(json_encode([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 7,
|
|
'method' => 'tools/call',
|
|
'params' => [
|
|
'name' => 'progress_tool',
|
|
'arguments' => [],
|
|
'_meta' => [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
|
'progressToken' => 'tok-1',
|
|
],
|
|
],
|
|
], \JSON_THROW_ON_ERROR), [
|
|
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
|
'Mcp-Method' => 'tools/call',
|
|
'Mcp-Name' => 'progress_tool',
|
|
'Accept' => 'application/json',
|
|
]);
|
|
|
|
$this->assertFalse($result->isStream());
|
|
$this->assertSame(200, $result->httpStatus);
|
|
}
|
|
|
|
#[TestDox('a request naming no log level receives no log notifications')]
|
|
public function testLoggingIsSilentWithoutARequestedLevel(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'logging_tool');
|
|
|
|
$this->assertFalse($result->isStream());
|
|
}
|
|
|
|
#[TestDox('a request naming a log level receives the messages at or above it')]
|
|
public function testLoggingHonoursTheRequestedLevel(): void
|
|
{
|
|
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'warning']));
|
|
|
|
$levels = [];
|
|
foreach ($frames as $frame) {
|
|
if ('notifications/message' === ($frame['method'] ?? null)) {
|
|
$levels[] = $frame['params']['level'];
|
|
}
|
|
}
|
|
|
|
// The tool emits debug, info, warning and error.
|
|
$this->assertSame(['warning', 'error'], $levels);
|
|
}
|
|
|
|
#[TestDox('a lower requested level lets more through')]
|
|
public function testLoggingAtDebugLetsEverythingThrough(): void
|
|
{
|
|
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'debug']));
|
|
|
|
$levels = [];
|
|
foreach ($frames as $frame) {
|
|
if ('notifications/message' === ($frame['method'] ?? null)) {
|
|
$levels[] = $frame['params']['level'];
|
|
}
|
|
}
|
|
|
|
$this->assertSame(['debug', 'info', 'warning', 'error'], $levels);
|
|
}
|
|
|
|
#[TestDox('an error raised before any notification keeps its own status')]
|
|
public function testEarlyFailureIsStillAStatusCode(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'capability_tool');
|
|
|
|
$this->assertFalse($result->isStream());
|
|
$this->assertSame(400, $result->httpStatus);
|
|
|
|
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
|
|
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']);
|
|
}
|
|
|
|
#[TestDox('a server-initiated request is refused with the pattern that replaced it')]
|
|
public function testServerInitiatedRequestIsRefused(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'elicits_directly');
|
|
|
|
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
|
|
|
|
$this->assertSame(500, $result->httpStatus);
|
|
$this->assertStringContainsString('InputRequiredResult', $body['error']['message']);
|
|
}
|
|
|
|
#[TestDox('a request\'s trace context reaches the handler')]
|
|
public function testTraceContextReachesTheHandler(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'tools/call',
|
|
[
|
|
'name' => 'probe_trace',
|
|
'arguments' => [],
|
|
'_meta' => [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
|
'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
|
|
'baggage' => 'tenant=acme',
|
|
],
|
|
],
|
|
['Mcp-Name' => 'probe_trace'],
|
|
);
|
|
|
|
$this->assertSame(
|
|
'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;baggage=tenant=acme',
|
|
$answer['body']['result']['content'][0]['text'],
|
|
);
|
|
}
|
|
|
|
#[TestDox('notifications caused by a traced request carry its trace context')]
|
|
public function testNotificationsCarryTheTraceContext(): void
|
|
{
|
|
$result = self::callStreaming(self::protocol(), 'progress_tool', [
|
|
'progressToken' => 'tok-1',
|
|
'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
|
|
]);
|
|
|
|
$frames = self::frames($result);
|
|
|
|
$this->assertSame(
|
|
'00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
|
|
$frames[0]['params']['_meta']['traceparent'],
|
|
);
|
|
}
|
|
|
|
#[TestDox('an untraced request adds no trace metadata')]
|
|
public function testUntracedRequestAddsNothing(): void
|
|
{
|
|
$frames = self::frames(self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']));
|
|
|
|
$this->assertArrayNotHasKey('traceparent', $frames[0]['params']['_meta'] ?? []);
|
|
}
|
|
|
|
#[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('resources/read can ask for input before it answers')]
|
|
public function testResourceReadCanAskForInput(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'resources/read',
|
|
['uri' => 'test://gated'],
|
|
['Mcp-Name' => 'test://gated'],
|
|
['elicitation' => new \stdClass()],
|
|
);
|
|
|
|
$this->assertSame(200, $answer['status']);
|
|
$this->assertSame('input_required', $answer['body']['result']['resultType']);
|
|
$this->assertArrayHasKey('who', $answer['body']['result']['inputRequests']);
|
|
|
|
// Interim results are not cacheable and carry no hints.
|
|
$this->assertArrayNotHasKey('ttlMs', $answer['body']['result']);
|
|
$this->assertArrayNotHasKey('cacheScope', $answer['body']['result']);
|
|
}
|
|
|
|
#[TestDox('an ask the client cannot answer is refused with -32021, not sent')]
|
|
public function testUndeclaredInputRequestIsRefused(): void
|
|
{
|
|
// The client declared nothing, so it has no way to answer an
|
|
// elicitation — and a retry carrying one could never arrive.
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'resources/read',
|
|
['uri' => 'test://gated'],
|
|
['Mcp-Name' => 'test://gated'],
|
|
);
|
|
|
|
$this->assertSame(400, $answer['status']);
|
|
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']);
|
|
$this->assertArrayHasKey('elicitation', $answer['body']['error']['data']['requiredCapabilities']);
|
|
}
|
|
|
|
#[TestDox('url-mode elicitation needs its own declaration, which form does not satisfy')]
|
|
public function testUrlElicitationNeedsItsOwnDeclaration(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'tools/call',
|
|
['name' => 'asks_by_url', 'arguments' => []],
|
|
['Mcp-Name' => 'asks_by_url'],
|
|
['elicitation' => new \stdClass()],
|
|
);
|
|
|
|
$this->assertSame(400, $answer['status']);
|
|
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']);
|
|
$this->assertArrayHasKey('url', $answer['body']['error']['data']['requiredCapabilities']['elicitation']);
|
|
}
|
|
|
|
#[TestDox('a client declaring url mode gets the ask')]
|
|
public function testUrlElicitationPassesWhenDeclared(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'tools/call',
|
|
['name' => 'asks_by_url', 'arguments' => []],
|
|
['Mcp-Name' => 'asks_by_url'],
|
|
['elicitation' => ['url' => new \stdClass()]],
|
|
);
|
|
|
|
$this->assertSame(200, $answer['status']);
|
|
$this->assertSame('input_required', $answer['body']['result']['resultType']);
|
|
}
|
|
|
|
#[TestDox('an ask the client cannot answer is refused with -32021 even mid-stream')]
|
|
public function testUndeclaredInputRequestIsRefusedWhenStreamed(): void
|
|
{
|
|
// The handler emits progress before its InputRequiredResult, so the
|
|
// answer goes out on the response stream, not a plain 400 — the gate
|
|
// still has to apply there.
|
|
$result = self::callStreaming(self::protocol(), 'asks_by_url_after_progress', ['progressToken' => 'tok-1']);
|
|
|
|
$this->assertTrue($result->isStream());
|
|
|
|
$frames = self::frames($result);
|
|
$last = json_decode(json_encode($frames[array_key_last($frames)], \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR);
|
|
|
|
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $last['error']['code']);
|
|
$this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']);
|
|
}
|
|
|
|
#[TestDox('a resource can set its own freshness, overriding the policy')]
|
|
public function testResourceAuthoredCacheHintsWin(): void
|
|
{
|
|
$protocol = Server::builder()
|
|
->setServerInfo('test-server', '1.0.0')
|
|
->setCachePolicy(CachePolicy::default(60_000, CacheScope::Public))
|
|
->addResource(
|
|
static fn (): ReadResourceResult => new ReadResourceResult(
|
|
[new TextResourceContents('test://volatile', 'text/plain', 'now')],
|
|
ttlMs: 250,
|
|
cacheScope: CacheScope::Private,
|
|
),
|
|
'test://volatile',
|
|
'volatile',
|
|
'A resource that decides its own freshness',
|
|
)
|
|
->addResource(static fn (): string => 'body', 'test://plain', 'plain', 'A resource that defers to policy')
|
|
->buildStateless([ProtocolVersion::V2026_07_28]);
|
|
|
|
$volatile = self::call($protocol, 'resources/read', ['uri' => 'test://volatile'], ['Mcp-Name' => 'test://volatile']);
|
|
$this->assertSame(250, $volatile['body']['result']['ttlMs']);
|
|
$this->assertSame('private', $volatile['body']['result']['cacheScope']);
|
|
|
|
$plain = self::call($protocol, 'resources/read', ['uri' => 'test://plain'], ['Mcp-Name' => 'test://plain']);
|
|
$this->assertSame(60_000, $plain['body']['result']['ttlMs']);
|
|
$this->assertSame('public', $plain['body']['result']['cacheScope']);
|
|
}
|
|
|
|
#[TestDox('a first-round read carries caching hints')]
|
|
public function testFirstRoundReadIsCacheable(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'resources/read',
|
|
['uri' => 'test://static'],
|
|
['Mcp-Name' => 'test://static'],
|
|
);
|
|
|
|
$this->assertArrayHasKey('ttlMs', $answer['body']['result']);
|
|
$this->assertArrayHasKey('cacheScope', $answer['body']['result']);
|
|
}
|
|
|
|
#[TestDox('a result produced by a multi round-trip retry carries no caching hints')]
|
|
public function testMrtrRetryResultIsNotCacheable(): void
|
|
{
|
|
$answer = self::call(
|
|
self::protocol(),
|
|
'resources/read',
|
|
[
|
|
'uri' => 'test://gated',
|
|
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['n' => 'ada']]],
|
|
],
|
|
['Mcp-Name' => 'test://gated'],
|
|
['elicitation' => new \stdClass()],
|
|
);
|
|
|
|
$this->assertSame('complete', $answer['body']['result']['resultType']);
|
|
// The inputs are not part of any cache key, so the answer must not be
|
|
// presented as reusable.
|
|
$this->assertArrayNotHasKey('ttlMs', $answer['body']['result']);
|
|
$this->assertArrayNotHasKey('cacheScope', $answer['body']['result']);
|
|
}
|
|
|
|
#[TestDox('a listen stream acknowledges, then carries what the client asked for')]
|
|
public function testListenStreamDeliversSubscribedNotifications(): void
|
|
{
|
|
$bus = new InMemoryNotificationBus();
|
|
|
|
$protocol = Server::builder()
|
|
->setServerInfo('test-server', '1.0.0')
|
|
->setCapabilities(new ServerCapabilities(toolsListChanged: true, resourcesListChanged: true, resourcesSubscribe: true))
|
|
->setNotificationBus($bus)
|
|
->setSubscriptionLifetime(0.8)
|
|
->addTool(static fn (): string => 'ok', name: 'a_tool', description: 'x')
|
|
->buildStateless([ProtocolVersion::V2026_07_28]);
|
|
|
|
$result = $protocol->handle(json_encode([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 42,
|
|
'method' => 'subscriptions/listen',
|
|
'params' => [
|
|
'notifications' => ['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']],
|
|
'_meta' => [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
|
],
|
|
],
|
|
], \JSON_THROW_ON_ERROR), [
|
|
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
|
'Mcp-Method' => 'subscriptions/listen',
|
|
]);
|
|
|
|
$this->assertTrue($result->isStream());
|
|
|
|
$frames = [];
|
|
$published = false;
|
|
|
|
foreach (($result->frames)() as $frame) {
|
|
if (null === $frame) {
|
|
// Publish once the stream is established, so the notifications
|
|
// arrive the way a concurrent request would deliver them.
|
|
if (!$published) {
|
|
$bus->publish(new ToolListChangedNotification());
|
|
$bus->publish(new PromptListChangedNotification());
|
|
$bus->publish(new ResourceUpdatedNotification('file:///watched'));
|
|
$bus->publish(new ResourceUpdatedNotification('file:///ignored'));
|
|
$published = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$frames[] = $frame;
|
|
}
|
|
|
|
$methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames);
|
|
|
|
// The acknowledgment MUST come first, the declined types must not come
|
|
// at all, and the graceful closure ends it.
|
|
$this->assertSame([
|
|
'notifications/subscriptions/acknowledged',
|
|
'notifications/tools/list_changed',
|
|
'notifications/resources/updated',
|
|
'response',
|
|
], $methods);
|
|
|
|
$this->assertSame(['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']], (array) $frames[0]['params']['notifications']);
|
|
$this->assertSame('file:///watched', $frames[2]['params']['uri']);
|
|
|
|
// Every message on the stream names the subscription it belongs to.
|
|
foreach ([$frames[0], $frames[1], $frames[2]] as $frame) {
|
|
$this->assertSame(42, $frame['params']['_meta'][RequestMeta::SUBSCRIPTION_ID]);
|
|
}
|
|
|
|
$this->assertSame(42, $frames[3]['id']);
|
|
$this->assertSame(42, $frames[3]['result']['_meta'][RequestMeta::SUBSCRIPTION_ID]);
|
|
$this->assertSame('complete', $frames[3]['result']['resultType']);
|
|
}
|
|
|
|
#[TestDox('the acknowledgment drops types the server cannot honour')]
|
|
public function testAcknowledgmentReflectsWhatTheServerCanDo(): void
|
|
{
|
|
$protocol = Server::builder()
|
|
->setServerInfo('test-server', '1.0.0')
|
|
->setCapabilities(new ServerCapabilities(toolsListChanged: true, promptsListChanged: false))
|
|
->setNotificationBus(new InMemoryNotificationBus())
|
|
->setSubscriptionLifetime(0.05)
|
|
->buildStateless([ProtocolVersion::V2026_07_28]);
|
|
|
|
$result = $protocol->handle(json_encode([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'method' => 'subscriptions/listen',
|
|
'params' => [
|
|
'notifications' => ['toolsListChanged' => true, 'promptsListChanged' => true],
|
|
'_meta' => [
|
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
|
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
|
],
|
|
],
|
|
], \JSON_THROW_ON_ERROR), [
|
|
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
|
'Mcp-Method' => 'subscriptions/listen',
|
|
]);
|
|
|
|
$first = null;
|
|
foreach (($result->frames)() as $frame) {
|
|
if (null !== $frame) {
|
|
$first = $frame;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']);
|
|
}
|
|
|
|
#[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']);
|
|
}
|
|
}
|