[Client] Speak the 2026-07-28 lifecycle (#456)
* [Client] Speak the 2026-07-28 lifecycle On that revision the client opens with server/discover instead of initialize, stamps each request's _meta with the protocol version, its own capabilities and client info, and sends the standard Mcp-Method / Mcp-Name / Mcp-Param-* headers an intermediary routes on. ToolCatalog is what knows, from the tool list, which arguments a call has to mirror. An input_required result is answered rather than surfaced: InputRequestResolver asks the host's elicitation, sampling and roots handlers and retries the same request carrying inputResponses and the requestState the server sent. McpHeader holds the header names and the =?base64?…?= sentinel, so the two sides no longer keep their own copies of the same rules. * Fix CI baseline drift and address Copilot review findings Drop stale expected-failure entries the new client fixture now passes. Copilot review: fix McpHeader round-trip for a wrapper-shaped literal, encode empty/numeric-keyed inputResponses as a JSON object, stop the incident example repeating side effects on MRTR retry, and reset ToolCatalog on reconnect instead of carrying the previous server's verdicts. * Skip DualEraExampleTestCase on PHP 8.1 PHP_CLI_SERVER_WORKERS does not reliably fork multiple php -S workers there (php/php-src#9400), reproducing the single-worker deadlock the extra workers exist to avoid.
This commit is contained in:
parent
cbceef06eb
commit
8c06dcecc6
11 changed files with 2443 additions and 248 deletions
|
|
@ -16,6 +16,7 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
|||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
|
|
@ -30,35 +31,53 @@ if (!$url || !$scenario) {
|
|||
exit(1);
|
||||
}
|
||||
|
||||
// The runner names the revision it is testing; without honouring it the client
|
||||
// would open every scenario with `initialize` and never reach the modern wire.
|
||||
$version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '')
|
||||
?? ProtocolVersion::V2025_11_25;
|
||||
|
||||
// Scenario-specific data (tool arguments, credentials) the runner passes in.
|
||||
$context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true);
|
||||
$context = is_array($context) ? $context : [];
|
||||
|
||||
@mkdir(__DIR__.'/logs', 0777, true);
|
||||
$logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true);
|
||||
$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url));
|
||||
$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s, version=%s', $scenario, $url, $version->value));
|
||||
|
||||
$builder = Client::builder()
|
||||
->setClientInfo('mcp-conformance-test-client', '1.0.0')
|
||||
->setProtocolVersion($version)
|
||||
->setInitTimeout(30)
|
||||
->setRequestTimeout(60)
|
||||
->setLogger($logger);
|
||||
|
||||
if ('elicitation-sep1034-client-defaults' === $scenario) {
|
||||
/**
|
||||
* Accepts every elicitation with an empty payload.
|
||||
*
|
||||
* Enough for the scenarios here, which check that the client asked and echoed
|
||||
* correctly rather than what a user would have typed.
|
||||
*/
|
||||
$acceptElicitation = new class($logger) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly Psr\Log\LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$this->logger->info('Received elicitation request, accepting with empty content');
|
||||
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
|
||||
}
|
||||
};
|
||||
|
||||
if (in_array($scenario, ['elicitation-sep1034-client-defaults', 'sep-2322-client-request-state'], true)) {
|
||||
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||
$builder->addRequestHandler(new class($logger) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly Psr\Log\LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$this->logger->info('Received elicitation request, accepting with empty content');
|
||||
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
|
||||
}
|
||||
});
|
||||
$builder->addRequestHandler($acceptElicitation);
|
||||
}
|
||||
|
||||
$client = $builder->build();
|
||||
|
|
@ -76,9 +95,11 @@ try {
|
|||
break;
|
||||
|
||||
case 'tools_call':
|
||||
$toolName = $toolsResult->tools[0]->name ?? 'test-tool';
|
||||
$client->callTool($toolName, []);
|
||||
$logger->info(sprintf('Called tool: %s', $toolName));
|
||||
// The scenario asserts both arguments arrive as numbers, so the
|
||||
// call has to be made by name with real values rather than
|
||||
// whatever tool happens to be listed first.
|
||||
$client->callTool('add_numbers', ['a' => 2, 'b' => 3]);
|
||||
$logger->info('Called tool: add_numbers');
|
||||
break;
|
||||
|
||||
case 'elicitation-sep1034-client-defaults':
|
||||
|
|
@ -87,6 +108,74 @@ try {
|
|||
$logger->info(sprintf('Called tool: %s', $toolName));
|
||||
break;
|
||||
|
||||
case 'json-schema-2020-12-preservation':
|
||||
// Round-trips the focal tool's inputSchema back through the echo
|
||||
// tool so the harness can diff what survived the client's parsing
|
||||
// (SEP-1613 keywords, plus the SEP-2106 vocabulary).
|
||||
$focal = null;
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
if ('json_schema_2020_12_tool' === $tool->name) {
|
||||
$focal = $tool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $focal) {
|
||||
throw new RuntimeException('Mock server did not advertise json_schema_2020_12_tool.');
|
||||
}
|
||||
|
||||
$client->callTool('json_schema_echo', ['schema' => $focal->inputSchema]);
|
||||
$logger->info('Echoed the observed inputSchema back via json_schema_echo');
|
||||
break;
|
||||
|
||||
case 'http-standard-headers':
|
||||
// Exercises every method that carries an Mcp-Method or Mcp-Name
|
||||
// header, including the ones whose subject needs Base64 wrapping.
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
$client->callTool($tool->name, []);
|
||||
}
|
||||
|
||||
$resources = $client->listResources();
|
||||
foreach ($resources->resources as $resource) {
|
||||
$client->readResource($resource->uri);
|
||||
}
|
||||
|
||||
$prompts = $client->listPrompts();
|
||||
foreach ($prompts->prompts as $prompt) {
|
||||
$client->getPrompt($prompt->name, []);
|
||||
}
|
||||
|
||||
$logger->info('Exercised every header-carrying method');
|
||||
break;
|
||||
|
||||
case 'http-custom-headers':
|
||||
// The runner supplies the exact argument values, each chosen to hit
|
||||
// a different corner of the encoding rules.
|
||||
foreach ($context['toolCalls'] ?? [] as $call) {
|
||||
$client->callTool($call['name'], $call['arguments'] ?? []);
|
||||
$logger->info(sprintf('Called tool: %s', $call['name']));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'http-invalid-tool-headers':
|
||||
// Only the tools that survived the listing are callable; calling
|
||||
// any of the malformed ones is the failure this scenario looks for.
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
$client->callTool($tool->name, ['region' => 'us-west1']);
|
||||
$logger->info(sprintf('Called tool: %s', $tool->name));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sep-2322-client-request-state':
|
||||
// Each tool drives one rule: echo the state back, omit it when none
|
||||
// was sent, keep an unrelated call clean, and treat a result with
|
||||
// no resultType as complete.
|
||||
foreach (['test_mrtr_echo_state', 'test_mrtr_unrelated', 'test_mrtr_no_state', 'test_mrtr_no_result_type'] as $tool) {
|
||||
$client->callTool($tool, []);
|
||||
$logger->info(sprintf('Called tool: %s', $tool));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$logger->warning(sprintf('Unknown scenario: %s', $scenario));
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
client:
|
||||
- elicitation-sep1034-client-defaults
|
||||
- sse-retry
|
||||
- tools_call:tool-add-numbers
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed
|
||||
- auth/metadata-default
|
||||
- auth/metadata-var1
|
||||
- auth/metadata-var2
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
client:
|
||||
- request-metadata
|
||||
- http-standard-headers
|
||||
- http-custom-headers
|
||||
- http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate
|
||||
- http-invalid-tool-headers:sep-2243-client-reject-invalid-tool
|
||||
- sep-2322-client-request-state
|
||||
- tools_call:tool-add-numbers
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed
|
||||
- auth/metadata-default
|
||||
- auth/metadata-var1
|
||||
- auth/metadata-var2
|
||||
|
|
|
|||
105
tests/Integration/DualEraElicitationTest.php
Normal file
105
tests/Integration/DualEraElicitationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?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\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* The elicitation example, driven from both eras against one endpoint.
|
||||
*
|
||||
* This is the example where the two lifecycles genuinely part ways: the
|
||||
* handshake era can interrupt a call to ask the user something, and the modern
|
||||
* era cannot. Its handlers fork in one place — a private `ask()` — and these
|
||||
* tests are what say the fork works both ways round.
|
||||
*/
|
||||
final class DualEraElicitationTest extends DualEraExampleTestCase
|
||||
{
|
||||
protected const ANSWERS = [
|
||||
'confirm' => true,
|
||||
'party_size' => 4,
|
||||
'date' => '2026-09-01',
|
||||
'dietary' => 'vegan',
|
||||
'rating' => '5',
|
||||
'comments' => 'Excellent',
|
||||
];
|
||||
|
||||
protected static function server(): string
|
||||
{
|
||||
return __DIR__.'/../../examples/server/elicitation/server.php';
|
||||
}
|
||||
|
||||
protected static function portBase(): int
|
||||
{
|
||||
return 9500;
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a confirmation is collected on $_dataName')]
|
||||
public function testConfirmAction(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']);
|
||||
|
||||
$this->assertStringContainsString('Action confirmed', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a multi-field form is collected on $_dataName')]
|
||||
public function testBookRestaurant(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']);
|
||||
|
||||
$this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('feedback with an optional field is collected on $_dataName')]
|
||||
public function testCollectFeedback(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']);
|
||||
|
||||
$this->assertStringContainsString('Thank you for your feedback', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')]
|
||||
public function testWithoutTheCapability(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
// Both eras refuse an ask the client cannot answer. The handshake era
|
||||
// finds out from the capability the client declared at initialize, the
|
||||
// modern era from the envelope on this very request — and the example
|
||||
// says the same thing either way.
|
||||
try {
|
||||
$answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything']));
|
||||
$this->assertStringContainsString('does not support elicitation', $answer);
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
}
|
||||
144
tests/Integration/DualEraExampleTestCase.php
Normal file
144
tests/Integration/DualEraExampleTestCase.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?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\Integration;
|
||||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Base for tests that drive one example server from both protocol eras.
|
||||
*
|
||||
* The example is started once, on one port, and every client in the test
|
||||
* reaches it at that one URL — which is the property being tested. Nothing here
|
||||
* mounts a second endpoint or restarts anything between eras.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
abstract class DualEraExampleTestCase extends TestCase
|
||||
{
|
||||
/** Answers whatever the server elicits, so a tool that asks can complete. */
|
||||
protected const ANSWERS = [];
|
||||
|
||||
private ?Process $server = null;
|
||||
private int $port;
|
||||
|
||||
/** Absolute path to the example's server script. */
|
||||
abstract protected static function server(): string;
|
||||
|
||||
/** Port range base, so concurrent test classes do not collide. */
|
||||
abstract protected static function portBase(): int;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on
|
||||
// PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the
|
||||
// extra workers exist to avoid: every request below hangs to the test
|
||||
// timeout instead of running. Skip until either PHP 8.1 is dropped or
|
||||
// this suite stops depending on `php -S` for multi-worker concurrency.
|
||||
if (\PHP_VERSION_ID < 80200) {
|
||||
$this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.');
|
||||
}
|
||||
|
||||
$this->port = static::portBase() + (getmypid() % 200);
|
||||
|
||||
// More than one worker because the handshake era needs it: a tool that
|
||||
// asks the client mid-call holds its SSE response open while the client
|
||||
// POSTs the answer on a second connection. One worker deadlocks there —
|
||||
// a property of `php -S`, not of the server.
|
||||
$this->server = new Process(
|
||||
['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()],
|
||||
env: ['PHP_CLI_SERVER_WORKERS' => '4'],
|
||||
);
|
||||
$this->server->start();
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while (microtime(true) < $deadline) {
|
||||
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep(50_000);
|
||||
}
|
||||
|
||||
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->server?->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{ProtocolVersion}>
|
||||
*/
|
||||
public static function provideEras(): iterable
|
||||
{
|
||||
yield 'the handshake era' => [ProtocolVersion::V2025_11_25];
|
||||
yield 'the modern era' => [ProtocolVersion::V2026_07_28];
|
||||
}
|
||||
|
||||
protected static function text(CallToolResult $result): string
|
||||
{
|
||||
$first = $result->content[0] ?? null;
|
||||
|
||||
self::assertInstanceOf(TextContent::class, $first);
|
||||
|
||||
return $first->text;
|
||||
}
|
||||
|
||||
protected function connect(ProtocolVersion $era, bool $elicitation = false): Client
|
||||
{
|
||||
$builder = Client::builder()
|
||||
->setClientInfo('dual-era-integration-client', '1.0.0')
|
||||
->setProtocolVersion($era)
|
||||
->setRequestTimeout(10);
|
||||
|
||||
if ($elicitation) {
|
||||
$answers = static::ANSWERS;
|
||||
|
||||
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||
$builder->addRequestHandler(new class($answers) implements RequestHandlerInterface {
|
||||
/** @param array<string, mixed> $answers */
|
||||
public function __construct(private readonly array $answers)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$client = $builder->build();
|
||||
$client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)));
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,11 +59,16 @@ final class HandshakeTest extends IntegrationTestCase
|
|||
yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25];
|
||||
yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18];
|
||||
|
||||
// Neither side reaches the modern era through `initialize`, so
|
||||
// configuring it falls back to the handshake set on both ends.
|
||||
yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest];
|
||||
// A modern client does not negotiate at all: it skips the handshake and
|
||||
// states its revision on every request, so what it was configured with
|
||||
// is what it reports. This server never answers `server/discover`, so
|
||||
// there is nothing to reconcile against either.
|
||||
yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2026_07_28];
|
||||
yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28];
|
||||
|
||||
// The server end still falls back: a handshake-era client offered a
|
||||
// revision, and `initialize` cannot answer with a modern one.
|
||||
yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18];
|
||||
yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest];
|
||||
}
|
||||
|
||||
#[TestDox('the handshake carries the server identity to the client')]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Mcp\Client\Configuration;
|
|||
use Mcp\Client\Protocol;
|
||||
use Mcp\Client\State\ClientStateInterface;
|
||||
use Mcp\Client\Transport\TransportInterface;
|
||||
use Mcp\Exception\ConnectionException;
|
||||
use Mcp\Exception\LogicException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
|
|
@ -22,6 +23,8 @@ use Mcp\Schema\Implementation;
|
|||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\MessageInterface;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\PingRequest;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
|
@ -42,20 +45,64 @@ final class ProtocolTest extends TestCase
|
|||
$this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion);
|
||||
}
|
||||
|
||||
#[TestDox('never offers a modern version over the initialize handshake, and warns about it')]
|
||||
public function testDoesNotOfferModernVersionOverHandshake(): void
|
||||
#[TestDox('never sends "initialize" on a modern revision, which removed it')]
|
||||
public function testModernRevisionSkipsTheHandshake(): void
|
||||
{
|
||||
$transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value);
|
||||
$protocol = new Protocol(logger: $logger = new CollectingLogger());
|
||||
$transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value);
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28));
|
||||
|
||||
$protocol->initialize($config);
|
||||
|
||||
$this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion);
|
||||
$this->assertSame([[
|
||||
'configured' => ProtocolVersion::V2026_07_28->value,
|
||||
'offered' => ProtocolVersion::latestHandshake()->value,
|
||||
]], $logger->warnings);
|
||||
$this->assertNotContains('initialize', $transport->methods);
|
||||
$this->assertNotContains('notifications/initialized', $transport->methods);
|
||||
$this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion());
|
||||
$this->assertTrue($protocol->getState()->isInitialized());
|
||||
}
|
||||
|
||||
#[TestDox('carries the revision, capabilities and client info on every modern request')]
|
||||
public function testModernRequestsCarryTheEnvelope(): void
|
||||
{
|
||||
$transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value);
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28));
|
||||
|
||||
$protocol->initialize($config);
|
||||
|
||||
$this->assertNotSame([], $transport->metas);
|
||||
|
||||
foreach ($transport->metas as $meta) {
|
||||
$this->assertSame(ProtocolVersion::V2026_07_28->value, $meta[RequestMeta::PROTOCOL_VERSION] ?? null);
|
||||
$this->assertArrayHasKey(RequestMeta::CLIENT_CAPABILITIES, $meta);
|
||||
$this->assertSame('client-app', $meta[RequestMeta::CLIENT_INFO]['name'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('a server that refuses "server/discover" still leaves a usable connection')]
|
||||
public function testDiscoveryFailureIsNotFatal(): void
|
||||
{
|
||||
$transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true);
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28));
|
||||
|
||||
$protocol->initialize($config);
|
||||
|
||||
$this->assertTrue($protocol->getState()->isInitialized());
|
||||
}
|
||||
|
||||
#[TestDox('refuses to continue when discovery shows the server has no modern revision')]
|
||||
public function testDiscoveryWithoutAModernRevisionFails(): void
|
||||
{
|
||||
// Advertising only handshake revisions leaves nothing this connection
|
||||
// can use: it has already skipped the handshake.
|
||||
$transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value);
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28));
|
||||
|
||||
$this->expectException(ConnectionException::class);
|
||||
$this->expectExceptionMessage('does not support any modern protocol revision');
|
||||
|
||||
$protocol->initialize($config);
|
||||
}
|
||||
|
||||
#[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')]
|
||||
|
|
@ -126,6 +173,41 @@ final class ProtocolTest extends TestCase
|
|||
$this->assertSame(Error::METHOD_NOT_FOUND, $response->code);
|
||||
}
|
||||
|
||||
#[TestDox('reconnecting starts with a fresh tool catalog, not the previous server\'s verdicts')]
|
||||
public function testReconnectResetsToolCatalog(): void
|
||||
{
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config = $this->createConfiguration(ProtocolVersion::V2025_11_25));
|
||||
|
||||
$protocol->getToolCatalog()->record([[
|
||||
'name' => 'broken',
|
||||
'inputSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']],
|
||||
],
|
||||
]]);
|
||||
|
||||
$this->assertTrue($protocol->getToolCatalog()->isRejected('broken'));
|
||||
|
||||
$protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config);
|
||||
|
||||
$this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect');
|
||||
}
|
||||
|
||||
#[TestDox('an empty inputResponses map is retried as a JSON object, never an array')]
|
||||
public function testEmptyInputResponsesEncodesAsJsonObject(): void
|
||||
{
|
||||
$transport = new InputRequiredRoundTripTransport();
|
||||
$protocol = new Protocol();
|
||||
$protocol->connect($transport, $this->createConfiguration(ProtocolVersion::V2026_07_28));
|
||||
|
||||
$result = $protocol->request(new PingRequest(), 5);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertStringContainsString('"inputResponses":{}', $transport->retryBody);
|
||||
$this->assertStringNotContainsString('"inputResponses":[]', $transport->retryBody);
|
||||
}
|
||||
|
||||
private function createConfiguration(ProtocolVersion $protocolVersion): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
|
|
@ -137,38 +219,164 @@ final class ProtocolTest extends TestCase
|
|||
}
|
||||
|
||||
/**
|
||||
* Transport that answers the `initialize` request inline with a canned
|
||||
* `protocolVersion`, so the handshake resolves without a Fiber round-trip.
|
||||
* Answers the first request with an empty `input_required` ask and the retry
|
||||
* with success, capturing the retry's raw body so the test can inspect how
|
||||
* `inputResponses` was actually encoded on the wire.
|
||||
*/
|
||||
final class InputRequiredRoundTripTransport implements TransportInterface
|
||||
{
|
||||
public string $retryBody = '';
|
||||
|
||||
private int $calls = 0;
|
||||
private ClientStateInterface $state;
|
||||
|
||||
public function setState(ClientStateInterface $state): void
|
||||
{
|
||||
$this->state = $state;
|
||||
}
|
||||
|
||||
public function send(string $data): void
|
||||
{
|
||||
/** @var array{id: int} $message */
|
||||
$message = json_decode($data, true);
|
||||
$id = $message['id'];
|
||||
|
||||
if (0 === $this->calls++) {
|
||||
$this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->retryBody = $data;
|
||||
|
||||
$this->answer($id, ['resultType' => 'complete']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function answer(int $id, array $result): void
|
||||
{
|
||||
$this->state->storeResponse($id, [
|
||||
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
|
||||
'id' => $id,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error
|
||||
{
|
||||
throw new LogicException('Not used in this test.');
|
||||
}
|
||||
|
||||
public function onInitialize(callable $callback): void
|
||||
{
|
||||
}
|
||||
|
||||
public function onMessage(callable $callback): void
|
||||
{
|
||||
}
|
||||
|
||||
public function onError(callable $callback): void
|
||||
{
|
||||
}
|
||||
|
||||
public function onClose(callable $callback): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that answers inline, so a request resolves without a Fiber
|
||||
* round-trip: `initialize` with a canned `protocolVersion`, and
|
||||
* `server/discover` with a minimal modern-era answer.
|
||||
*/
|
||||
final class RecordingTransport implements TransportInterface
|
||||
{
|
||||
public ?string $offeredVersion = null;
|
||||
|
||||
/** @var list<string> every method that reached the wire, in order */
|
||||
public array $methods = [];
|
||||
|
||||
/** @var list<array<string, mixed>> the `_meta` each request carried */
|
||||
public array $metas = [];
|
||||
|
||||
private ClientStateInterface $state;
|
||||
|
||||
public function __construct(private readonly string $counterOffer)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $counterOffer,
|
||||
private readonly bool $refuseDiscovery = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function send(string $data): void
|
||||
{
|
||||
/** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */
|
||||
/** @var array{id?: int|string, method?: string, params?: array<string, mixed>} $message */
|
||||
$message = json_decode($data, true);
|
||||
$method = $message['method'] ?? null;
|
||||
|
||||
if ('initialize' !== ($message['method'] ?? null)) {
|
||||
if (!\is_string($method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->offeredVersion = $message['params']['protocolVersion'] ?? null;
|
||||
$this->methods[] = $method;
|
||||
$this->metas[] = $message['params']['_meta'] ?? [];
|
||||
|
||||
$this->state->storeResponse($message['id'], [
|
||||
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
|
||||
'id' => $message['id'],
|
||||
'result' => [
|
||||
if (!isset($message['id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('initialize' === $method) {
|
||||
$this->offeredVersion = $message['params']['protocolVersion'] ?? null;
|
||||
|
||||
$this->answer($message['id'], [
|
||||
'protocolVersion' => $this->counterOffer,
|
||||
'capabilities' => [],
|
||||
'serverInfo' => ['name' => 'server', 'version' => '1.2.3'],
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('server/discover' !== $method) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->refuseDiscovery) {
|
||||
$this->state->storeResponse($message['id'], [
|
||||
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
|
||||
'id' => $message['id'],
|
||||
'error' => ['code' => -32601, 'message' => 'Method not found'],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->answer($message['id'], [
|
||||
'resultType' => 'complete',
|
||||
'supportedVersions' => [$this->counterOffer],
|
||||
'capabilities' => [],
|
||||
'serverInfo' => ['name' => 'server', 'version' => '1.2.3'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function answer(int|string $id, array $result): void
|
||||
{
|
||||
$this->state->storeResponse($id, [
|
||||
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
|
||||
'id' => $id,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
124
tests/Unit/Client/Stateless/HeaderFactoryTest.php
Normal file
124
tests/Unit/Client/Stateless/HeaderFactoryTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Client\Stateless\HeaderFactory;
|
||||
use Mcp\Client\Stateless\ToolCatalog;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Server\Stateless\StandardHeaderValidator;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* What the client puts on the wire, checked against the server that reads it.
|
||||
*
|
||||
* Every case asserts the emitted headers and then feeds them straight to
|
||||
* {@see StandardHeaderValidator}: agreeing with a hand-written expectation is
|
||||
* not the point, agreeing with the other side is.
|
||||
*/
|
||||
final class HeaderFactoryTest extends TestCase
|
||||
{
|
||||
#[TestDox('declares the revision and the method on every request')]
|
||||
public function testStandardHeaders(): void
|
||||
{
|
||||
$headers = $this->headersFor(['method' => 'tools/list', 'params' => []]);
|
||||
|
||||
$this->assertSame('2026-07-28', $headers['MCP-Protocol-Version']);
|
||||
$this->assertSame('tools/list', $headers['Mcp-Method']);
|
||||
$this->assertArrayNotHasKey('Mcp-Name', $headers);
|
||||
}
|
||||
|
||||
#[TestDox('names the subject of a request that addresses one')]
|
||||
public function testNameHeader(): void
|
||||
{
|
||||
$payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []]];
|
||||
|
||||
$this->assertSame('search', $this->headersFor($payload)['Mcp-Name']);
|
||||
$this->assertNull($this->validate($payload));
|
||||
}
|
||||
|
||||
#[TestDox('wraps a subject that is not header-safe, and the server unwraps it')]
|
||||
public function testUnsafeNameRoundTrips(): void
|
||||
{
|
||||
$payload = ['method' => 'resources/read', 'params' => ['uri' => 'file:///café.txt']];
|
||||
|
||||
$this->assertSame('=?base64?ZmlsZTovLy9jYWbDqS50eHQ=?=', $this->headersFor($payload)['Mcp-Name']);
|
||||
$this->assertNull($this->validate($payload));
|
||||
}
|
||||
|
||||
#[TestDox('mirrors annotated tool arguments the server can verify')]
|
||||
public function testMirroredArguments(): void
|
||||
{
|
||||
$payload = [
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => 'search',
|
||||
'arguments' => ['region' => ' padded ', 'priority' => 7],
|
||||
],
|
||||
];
|
||||
|
||||
$headers = $this->headersFor($payload);
|
||||
|
||||
$this->assertSame('=?base64?IHBhZGRlZCA=?=', $headers['Mcp-Param-Region']);
|
||||
$this->assertSame('7', $headers['Mcp-Param-Priority']);
|
||||
$this->assertNull($this->validate($payload));
|
||||
}
|
||||
|
||||
#[TestDox('a response carries the revision but nothing to mirror')]
|
||||
public function testResponseCarriesOnlyTheVersion(): void
|
||||
{
|
||||
$headers = $this->headersFor(['id' => 1, 'result' => []]);
|
||||
|
||||
$this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function headersFor(array $payload): array
|
||||
{
|
||||
return (new HeaderFactory($this->catalog()))->forMessage($payload, ProtocolVersion::V2026_07_28);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return string|null the server's reason to reject, or null when it agrees
|
||||
*/
|
||||
private function validate(array $payload): ?string
|
||||
{
|
||||
return (new StandardHeaderValidator())->validate(
|
||||
$payload['method'],
|
||||
$payload['params'] ?? null,
|
||||
$this->headersFor($payload),
|
||||
);
|
||||
}
|
||||
|
||||
private function catalog(): ToolCatalog
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
$catalog->record([[
|
||||
'name' => 'search',
|
||||
'inputSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
|
||||
'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
}
|
||||
119
tests/Unit/Client/Stateless/InputRequestResolverTest.php
Normal file
119
tests/Unit/Client/Stateless/InputRequestResolverTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Stateless\InputRequestResolver;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InputRequestResolverTest extends TestCase
|
||||
{
|
||||
#[TestDox('a result with no resultType is complete, not an ask')]
|
||||
public function testDefaultResultTypeIsComplete(): void
|
||||
{
|
||||
$this->assertNull(InputRequestResolver::asked(['content' => []]));
|
||||
$this->assertNull(InputRequestResolver::asked(['resultType' => 'complete']));
|
||||
}
|
||||
|
||||
#[TestDox('an input_required result is an ask, even with an empty map')]
|
||||
public function testInputRequiredIsAnAsk(): void
|
||||
{
|
||||
$this->assertSame([], InputRequestResolver::asked(['resultType' => 'input_required']));
|
||||
$this->assertSame(
|
||||
['confirm' => ['method' => 'elicitation/create']],
|
||||
InputRequestResolver::asked([
|
||||
'resultType' => 'input_required',
|
||||
'inputRequests' => ['confirm' => ['method' => 'elicitation/create']],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('answers each ask under the key the server asked it under')]
|
||||
public function testAnswersAreKeyedByTheServersKey(): void
|
||||
{
|
||||
$responses = $this->resolver()->resolve([
|
||||
'confirm' => self::elicitation('Confirm?'),
|
||||
'name' => self::elicitation('Your name?'),
|
||||
]);
|
||||
|
||||
$this->assertSame(['confirm', 'name'], array_keys($responses));
|
||||
$this->assertSame('accept', $responses['confirm']['action']);
|
||||
}
|
||||
|
||||
#[TestDox('refuses an ask the client has no handler for')]
|
||||
public function testUnhandledAskIsRefused(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Client does not handle "elicitation/create" requests.');
|
||||
|
||||
(new InputRequestResolver([]))->resolve(['confirm' => self::elicitation('Confirm?')]);
|
||||
}
|
||||
|
||||
#[TestDox('refuses an ask that is not a request a client can answer')]
|
||||
public function testUnanswerableMethodIsRefused(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('which is not a request a client can answer');
|
||||
|
||||
$this->resolver()->resolve(['x' => ['method' => 'tools/call', 'params' => []]]);
|
||||
}
|
||||
|
||||
#[TestDox('refuses an ask with no method to answer')]
|
||||
public function testMethodlessAskIsRefused(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('without a method to answer');
|
||||
|
||||
$this->resolver()->resolve(['x' => ['params' => []]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function elicitation(string $message): array
|
||||
{
|
||||
return [
|
||||
'method' => 'elicitation/create',
|
||||
'params' => [
|
||||
'message' => $message,
|
||||
'requestedSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['confirmed' => ['type' => 'boolean']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolver(): InputRequestResolver
|
||||
{
|
||||
return new InputRequestResolver([
|
||||
new class implements RequestHandlerInterface {
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
|
||||
}
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
124
tests/Unit/Client/Stateless/ToolCatalogTest.php
Normal file
124
tests/Unit/Client/Stateless/ToolCatalogTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Client\Stateless\ToolCatalog;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ToolCatalogTest extends TestCase
|
||||
{
|
||||
#[TestDox('mirrors an annotated argument into its header')]
|
||||
public function testMirrorsAnnotatedArguments(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
$catalog->record([self::annotatedTool()]);
|
||||
|
||||
$this->assertSame(
|
||||
['Region' => 'us-west1', 'Priority' => '7', 'Verbose' => 'false'],
|
||||
$catalog->headersFor('search', ['region' => 'us-west1', 'priority' => 7, 'verbose' => false]),
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('an argument that is absent or null contributes no header')]
|
||||
public function testOmittedArgumentsAreNotMirrored(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
$catalog->record([self::annotatedTool()]);
|
||||
|
||||
// An omitted header is how "no value" is said; an empty one would
|
||||
// assert that the argument was present and empty.
|
||||
$this->assertSame(
|
||||
['Region' => 'us-west1'],
|
||||
$catalog->headersFor('search', ['region' => 'us-west1', 'verbose' => null]),
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('an unannotated argument is never mirrored')]
|
||||
public function testUnannotatedArgumentsAreNotMirrored(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
$catalog->record([self::annotatedTool()]);
|
||||
|
||||
$this->assertSame([], $catalog->headersFor('search', ['query' => 'SELECT 1']));
|
||||
}
|
||||
|
||||
#[TestDox('a tool the client never listed is not second-guessed')]
|
||||
public function testUnknownToolIsNeitherMirroredNorRejected(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
|
||||
$this->assertSame([], $catalog->headersFor('unlisted', ['region' => 'x']));
|
||||
$this->assertFalse($catalog->isRejected('unlisted'));
|
||||
}
|
||||
|
||||
#[TestDox('a malformed annotation drops that tool and leaves the rest usable')]
|
||||
public function testMalformedToolIsDroppedAlone(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
|
||||
$usable = $catalog->record([
|
||||
self::annotatedTool(),
|
||||
[
|
||||
'name' => 'broken',
|
||||
'inputSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(['search'], array_column($usable, 'name'));
|
||||
$this->assertTrue($catalog->isRejected('broken'));
|
||||
$this->assertFalse($catalog->isRejected('search'));
|
||||
$this->assertStringContainsString('only string, integer and boolean', (string) $catalog->reasonFor('broken'));
|
||||
}
|
||||
|
||||
#[TestDox('a later listing replaces what was known about a tool')]
|
||||
public function testRelistingReplacesTheVerdict(): void
|
||||
{
|
||||
$catalog = new ToolCatalog();
|
||||
$catalog->record([[
|
||||
'name' => 'search',
|
||||
'inputSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['region' => ['type' => 'object', 'x-mcp-header' => 'Region']],
|
||||
],
|
||||
]]);
|
||||
|
||||
$this->assertTrue($catalog->isRejected('search'));
|
||||
|
||||
$catalog->record([self::annotatedTool()]);
|
||||
|
||||
$this->assertFalse($catalog->isRejected('search'));
|
||||
$this->assertSame(['Region' => 'eu'], $catalog->headersFor('search', ['region' => 'eu']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function annotatedTool(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'search',
|
||||
'inputSchema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
|
||||
'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'],
|
||||
'verbose' => ['type' => 'boolean', 'x-mcp-header' => 'Verbose'],
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
93
tests/Unit/Schema/Wire/McpHeaderTest.php
Normal file
93
tests/Unit/Schema/Wire/McpHeaderTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?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\Schema\Wire;
|
||||
|
||||
use Mcp\Schema\Wire\McpHeader;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The encoding rules both sides of the wire depend on (SEP-2243).
|
||||
*
|
||||
* The round-trip property is what actually matters: whatever the client wraps,
|
||||
* the server's reader must recover unchanged. A rule that only one side gets
|
||||
* right produces a header mismatch the caller cannot explain.
|
||||
*/
|
||||
final class McpHeaderTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{mixed, string}>
|
||||
*/
|
||||
public static function provideValues(): iterable
|
||||
{
|
||||
yield 'plain ascii' => ['us-west1', 'us-west1'];
|
||||
yield 'empty string' => ['', ''];
|
||||
yield 'interior spaces stay plain' => ['us west 1', 'us west 1'];
|
||||
yield 'integer' => [42, '42'];
|
||||
yield 'boolean true' => [true, 'true'];
|
||||
yield 'boolean false' => [false, 'false'];
|
||||
yield 'non-ascii is wrapped' => ['Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?='];
|
||||
yield 'leading space is wrapped' => [' us-west1', '=?base64?IHVzLXdlc3Qx?='];
|
||||
yield 'trailing space is wrapped' => ['us-west1 ', '=?base64?dXMtd2VzdDEg?='];
|
||||
yield 'newline is wrapped' => ["line1\nline2", '=?base64?bGluZTEKbGluZTI=?='];
|
||||
yield 'tab is wrapped' => ["\tindented", '=?base64?CWluZGVudGVk?='];
|
||||
yield 'a literal already shaped like the wrapper is wrapped again' => ['=?base64?SGVsbG8=?=', '=?base64?PT9iYXNlNjQ/U0dWc2JHOD0/PQ==?='];
|
||||
}
|
||||
|
||||
#[DataProvider('provideValues')]
|
||||
#[TestDox('renders a mirrored argument as a header value')]
|
||||
public function testEncode(mixed $value, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, McpHeader::encode($value));
|
||||
}
|
||||
|
||||
#[DataProvider('provideValues')]
|
||||
#[TestDox('what the client wraps, the server recovers unchanged')]
|
||||
public function testRoundTrip(mixed $value, string $encoded): void
|
||||
{
|
||||
$expected = match (true) {
|
||||
\is_bool($value) => $value ? 'true' : 'false',
|
||||
default => (string) $value,
|
||||
};
|
||||
|
||||
$this->assertSame($expected, McpHeader::decode($encoded));
|
||||
}
|
||||
|
||||
#[TestDox('a value that cannot be mirrored gets no header at all')]
|
||||
public function testUnmirrorableValue(): void
|
||||
{
|
||||
// A float has no single decimal spelling for a receiver to compare
|
||||
// against, which is why SEP-2243 forbids the annotation on `number`.
|
||||
$this->assertNull(McpHeader::encode(3.14159));
|
||||
$this->assertNull(McpHeader::encode(['a']));
|
||||
$this->assertNull(McpHeader::encode(null));
|
||||
}
|
||||
|
||||
#[TestDox('a corrupted wrapper is refused rather than silently decoded')]
|
||||
public function testCorruptWrapperIsRefused(): void
|
||||
{
|
||||
$this->assertNull(McpHeader::decode('=?base64?not valid base64!?='));
|
||||
}
|
||||
|
||||
#[TestDox('names the subject of the methods that address one, and nothing else')]
|
||||
public function testNameFor(): void
|
||||
{
|
||||
$this->assertSame('my-tool', McpHeader::nameFor('tools/call', ['name' => 'my-tool']));
|
||||
$this->assertSame('my-prompt', McpHeader::nameFor('prompts/get', ['name' => 'my-prompt']));
|
||||
$this->assertSame('file:///x', McpHeader::nameFor('resources/read', ['uri' => 'file:///x']));
|
||||
$this->assertSame('t-1', McpHeader::nameFor('tasks/get', ['taskId' => 't-1']));
|
||||
|
||||
$this->assertNull(McpHeader::nameFor('tools/list', []));
|
||||
$this->assertNull(McpHeader::nameFor('tools/call', null));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue