[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:
Christopher Hertel 2026-08-19 02:41:02 +02:00 • committed by GitHub
commit 8c06dcecc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2443 additions and 248 deletions

View file

@ -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;

View file

@ -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

View file

@ -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

View 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();
}
}

View 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;
}
}

View file

@ -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')]

View file

@ -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,
]);
}

View 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;
}
}

View 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, []));
}
},
]);
}
}

View 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'],
],
],
];
}
}

View 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));
}
}