From 6d67fd80f9fd4339320ed5ed72cfbde5d87bb3ea Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:45:01 +0200 Subject: [PATCH 01/23] [Client] Probe for the modern era and fall back to the handshake --- src/Client/Builder.php | 23 +- src/Client/Configuration.php | 13 + src/Client/Protocol.php | 316 +++++++++++++++++------- tests/Unit/Client/ConfigurationTest.php | 18 ++ tests/Unit/Client/ProtocolTest.php | 173 +++++++++++-- 5 files changed, 431 insertions(+), 112 deletions(-) diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 098b6e6ae..af6cedf8e 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -35,6 +35,7 @@ final class Builder private ?string $description = null; private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; + private ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25; private ?ClientCapabilities $capabilities = null; /** @var array> */ @@ -66,7 +67,12 @@ public function setClientInfo(string $name, string $version, ?string $descriptio } /** - * Set the protocol version to use. + * Set the protocol version the client prefers. + * + * Defaults to 2025-11-25. A modern revision is probed for with + * `server/discover` and falls back to the `initialize` handshake when the + * server turns out not to speak it, see {@see self::setFallbackProtocolVersion()}; + * a handshake revision skips the probe and opens with the handshake. */ public function setProtocolVersion(ProtocolVersion $protocolVersion): self { @@ -75,6 +81,20 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self return $this; } + /** + * Set the handshake revision a modern client falls back to when the server + * does not speak the modern era. Defaults to 2025-11-25. + * + * Null makes the client modern-only: a server without the modern era then + * fails the connection instead. + */ + public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self + { + $this->fallbackProtocolVersion = $protocolVersion; + + return $this; + } + /** * Set client capabilities. */ @@ -202,6 +222,7 @@ public function build(): Client initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, maxRetries: $this->maxRetries, + fallbackProtocolVersion: $this->fallbackProtocolVersion, ); $protocol = new Protocol( diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index f0ed6f73e..b01d21767 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -23,6 +23,14 @@ */ class Configuration { + /** + * @param ProtocolVersion $protocolVersion the revision the client prefers. A modern one is + * probed for with `server/discover` before anything else + * @param ProtocolVersion|null $fallbackProtocolVersion the handshake revision offered through `initialize` when + * a probe shows the server does not speak the modern era; + * null makes a modern client modern-only. Unused when + * $protocolVersion is a handshake revision already + */ public function __construct( public readonly Implementation $clientInfo, public readonly ClientCapabilities $capabilities, @@ -30,7 +38,12 @@ public function __construct( public readonly int $initTimeout = 30, public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, + public readonly ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25, ) { + if (null !== $fallbackProtocolVersion && $fallbackProtocolVersion->isModern()) { + throw new InvalidArgumentException(\sprintf('The fallback protocol version must be one reached through the "initialize" handshake, got "%s".', $fallbackProtocolVersion->value)); + } + if ($initTimeout < 1) { throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout)); } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 251826c75..8a1a6fa01 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -23,7 +23,6 @@ use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Client\Transport\TransportInterface; -use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; use Mcp\JsonRpc\MessageFactory; @@ -139,15 +138,6 @@ public function connect(TransportInterface $transport, Configuration $config): v // or another — has said nothing yet. $this->tools = new ToolCatalog($this->logger); - if ($config->protocolVersion->isModern()) { - $this->envelope = new RequestEnvelope( - $config->protocolVersion, - $config->capabilities, - $config->clientInfo, - ); - $this->headers = new HeaderFactory($this->tools); - } - $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); @@ -179,11 +169,13 @@ private function headersFor(string $payload): array } /** - * Ready the connection for use. + * Ready the connection for use, settling which protocol era it speaks. * - * Up to 2025-11-25 that means the `initialize` handshake: offer a revision, - * take the server's answer, confirm with `notifications/initialized`. From - * 2026-07-28 there is no handshake at all — see {@see self::discover()}. + * A handshake revision opens with `initialize`, as every revision up to + * 2025-11-25 does. A modern one has no handshake: the client probes with + * `server/discover` instead, and falls back to the handshake when the + * answer shows the server does not speak the modern era — see + * {@see self::negotiate()}. * * @param Configuration $config The client configuration * @@ -191,12 +183,196 @@ private function headersFor(string $payload): array */ public function initialize(Configuration $config): Response|Error { - if (null !== $this->envelope) { - return $this->discover($config); + // Settled anew on every attempt: a reconnect may reach another server. + $this->envelope = null; + $this->headers = null; + + if (!$config->protocolVersion->isModern()) { + return $this->handshake($config->protocolVersion, $config); + } + + return $this->negotiate($config); + } + + /** + * Probe for the modern era, falling back to the handshake when the server + * does not speak it. + * + * Only positive evidence keeps the connection modern: a `DiscoverResult` + * naming a modern revision this client speaks, or a refusal naming one + * (which {@see self::request()} has already retried with). Any other error, + * silence until the timeout, or a server advertising nothing but handshake + * revisions identifies a server from before the modern era. The fallback is + * deliberately not keyed to any one error code: such servers answer an + * unknown request before `initialize` however they like, or not at all. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility + * + * @return Response>|Error + */ + private function negotiate(Configuration $config): Response|Error + { + $version = $config->protocolVersion; + + // Twice at most: a probe that timed out here may still have reached a + // slow-starting server and settled it on the modern era, which the + // fallback handshake then hears about as a refusal naming that era. + for ($attempt = 0; $attempt < 2; ++$attempt) { + $this->enterModernEra($version, $config); + + $probe = $this->request(new DiscoverRequest(), $config->initTimeout); + $adopted = $this->adopt($probe, $config); + + if ($adopted instanceof Response || $adopted instanceof Error) { + return $adopted; + } + + if (null === $config->fallbackProtocolVersion) { + return Error::forInvalidRequest(\sprintf( + 'Server does not speak protocol version %s and this client is configured without a handshake fallback: %s', + $config->protocolVersion->value, + self::describe($probe), + )); + } + + $this->logger->info('Server does not speak the modern era; falling back to the "initialize" handshake.', [ + 'probe' => self::describe($probe), + 'offering' => $config->fallbackProtocolVersion->value, + ]); + + $this->envelope = null; + $this->headers = null; + + $handshake = $this->handshake($config->fallbackProtocolVersion, $config); + + if ($handshake instanceof Error && 0 === $attempt && null !== $modern = self::mutualModern($handshake)) { + $this->logger->info('Server settled on the modern era after all; probing again.', ['version' => $modern->value]); + $version = $modern; + + continue; + } + + return $handshake; + } + + return Error::forInternalError('Protocol negotiation did not settle on a revision.'); + } + + private function enterModernEra(ProtocolVersion $version, Configuration $config): void + { + $this->envelope = new RequestEnvelope($version, $config->capabilities, $config->clientInfo); + $this->headers = new HeaderFactory($this->tools); + } + + /** + * Reads a probe's answer: the connection, now modern; an error ending the + * attempt; or null when the server is not a modern one. + * + * @param Response>|Error $probe + * + * @return Response>|Error|null + */ + private function adopt(Response|Error $probe, Configuration $config): Response|Error|null + { + \assert(null !== $this->envelope); + + if ($probe instanceof Error) { + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $probe->code) { + return null; + } + + // A refusal naming a modern revision was already retried with it, + // so reaching here means it named none this client speaks. A server + // naming handshake revisions is still reachable through them; one + // naming neither is a modern server this client cannot talk to. + $supported = self::supportedVersions($probe); + + foreach ($supported as $version) { + if (!$version->isModern()) { + return null; + } + } + + $named = \is_array($probe->data['supported'] ?? null) ? array_filter($probe->data['supported'], is_string(...)) : []; + + return Error::forInvalidRequest(\sprintf('Server supports none of the protocol versions this client speaks (it advertises %s).', [] === $named ? 'none' : implode(', ', $named)), $probe->id); + } + + $advertised = $probe->result['supportedVersions'] ?? null; + + if (!\is_array($advertised)) { + // Not a DiscoverResult, so not evidence of the modern era. A client + // with nowhere else to go keeps the revision it was configured with. + if (null !== $config->fallbackProtocolVersion) { + return null; + } + + $this->readDiscovery($probe->result); + + return $this->settleModern($probe); + } + + $current = $this->envelope->protocolVersion(); + $chosen = null; + + foreach (ProtocolVersion::modernVersions() as $version) { + if (\in_array($version->value, $advertised, true)) { + $chosen = $version; + } + } + + if (\in_array($current->value, $advertised, true)) { + $chosen = $current; + } + + if (null === $chosen) { + // It speaks discover but advertises only handshake revisions: a + // statement of where it can be reached, not an incompatibility. + return null; + } + + if ($chosen !== $current) { + $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ + 'configured' => $current->value, + 'using' => $chosen->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($chosen); } - $offered = $config->protocolVersion; + $this->readDiscovery($probe->result); + + return $this->settleModern($probe); + } + + /** + * @param Response> $probe + * + * @return Response> + */ + private function settleModern(Response $probe): Response + { + \assert(null !== $this->envelope); + + $this->state->setProtocolVersion($this->envelope->protocolVersion()); + $this->state->setInitialized(true); + + $this->logger->info('Connection settled on the modern era', [ + 'protocolVersion' => $this->envelope->protocolVersion()->value, + ]); + return $probe; + } + + /** + * The `initialize` handshake: offer a revision, take the server's answer, + * confirm with `notifications/initialized`. + * + * @return Response>|Error + */ + private function handshake(ProtocolVersion $offered, Configuration $config): Response|Error + { $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -244,41 +420,7 @@ public function initialize(Configuration $config): Response|Error } /** - * Stand in for the handshake in the modern era. - * - * There is nothing to negotiate: the revision travels on every request, so - * the connection is usable the moment the transport is. `server/discover` - * is only asked because the facade exposes `getServerInfo()` and - * `getServerCapabilities()`, and a server that will not answer it still - * serves every other method — so a failure here is logged and the - * connection proceeds. - * - * @return Response> - */ - private function discover(Configuration $config): Response - { - $this->state->setProtocolVersion($config->protocolVersion); - $this->state->setInitialized(true); - - $response = $this->request(new DiscoverRequest(), $config->initTimeout); - - if ($response instanceof Error) { - $this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [ - 'code' => $response->code, - 'message' => $response->message, - ]); - - return new Response(0, []); - } - - $this->readDiscovery($response->result); - - return $response; - } - - /** - * Read defensively: `server/discover` is optional, so a server may answer - * with something that is not a DiscoverResult at all, and none of it is + * Read defensively: none of a `DiscoverResult` beyond its revisions is * load-bearing for the requests that follow. * * @param array $result @@ -306,56 +448,52 @@ private function readDiscovery(array $result): void if (\is_array($result['capabilities'] ?? null)) { $this->state->setServerCapabilities(ServerCapabilities::fromArray($result['capabilities'])); } - - $this->reconcileVersion($result['supportedVersions'] ?? null); - - $this->logger->info('Discovery complete', [ - 'supportedVersions' => $result['supportedVersions'] ?? null, - ]); } /** - * Move to a revision the server actually speaks, if it said which. - * - * `server/discover` reports rather than negotiates, so a client that asked - * for something the server does not list learns it here — and learning it - * now is far better than a stream of refusals later. A server that stays - * silent about its versions is left alone; the method is optional and - * saying nothing is not the same as saying no. + * The newest modern revision a refusal names that this client speaks. */ - private function reconcileVersion(mixed $supportedVersions): void + private static function mutualModern(Error $error): ?ProtocolVersion { - if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) { - return; - } - - $current = $this->envelope->protocolVersion(); - - if (\in_array($current->value, $supportedVersions, true)) { - return; + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code) { + return null; } - foreach ($supportedVersions as $candidate) { - $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + $mutual = null; - if (null === $version || !$version->isModern()) { - continue; + foreach (self::supportedVersions($error) as $version) { + if ($version->isModern() && (null === $mutual || $version->isAtLeast($mutual))) { + $mutual = $version; } + } - $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ - 'configured' => $current->value, - 'using' => $version->value, - ]); + return $mutual; + } - $this->envelope = $this->envelope->withProtocolVersion($version); - $this->state->setProtocolVersion($version); + /** + * The revisions a `-32022` refusal names, as far as this SDK knows them. + * + * @return list + */ + private static function supportedVersions(Error $error): array + { + $data = \is_array($error->data) ? $error->data : []; + $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; - return; - } + return array_values(array_filter(array_map( + static fn (mixed $v): ?ProtocolVersion => \is_string($v) ? ProtocolVersion::tryFrom($v) : null, + $supported, + ))); + } - // Everything it offers is handshake era, which this connection cannot - // reach — it has already skipped the handshake. - throw new ConnectionException(\sprintf('Server does not support any modern protocol revision (it advertises %s); the configured "%s" cannot be used against it.', implode(', ', array_map(strval(...), $supportedVersions)), $current->value)); + /** + * @param Response>|Error $probe + */ + private static function describe(Response|Error $probe): string + { + return $probe instanceof Error + ? \sprintf('"server/discover" was answered with error %d (%s)', $probe->code, $probe->message) + : '"server/discover" was answered without a modern revision'; } /** diff --git a/tests/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php index ae50a4991..52111d4ae 100644 --- a/tests/Unit/Client/ConfigurationTest.php +++ b/tests/Unit/Client/ConfigurationTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Configuration; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -51,6 +52,23 @@ public static function provideNonPositiveTimeouts(): iterable yield 'negative' => [-1]; } + #[TestDox('falls back to the newest handshake revision by default')] + public function testDefaultsToTheNewestHandshakeFallback(): void + { + $config = new Configuration(new Implementation('client', '1.0.0'), new ClientCapabilities()); + + $this->assertSame(ProtocolVersion::latestHandshake(), $config->fallbackProtocolVersion); + } + + #[TestDox('a modern revision cannot be the handshake fallback')] + public function testModernFallbackIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The fallback protocol version must be one reached through the "initialize" handshake, got "2026-07-28".'); + + new Configuration(new Implementation('client', '1.0.0'), new ClientCapabilities(), fallbackProtocolVersion: ProtocolVersion::V2026_07_28); + } + #[TestDox('the builder rejects a non-positive initialization timeout')] public function testBuilderRejectsNonPositiveInitTimeout(): void { diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index ad2503425..ae3af89ae 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -83,32 +83,127 @@ public function testModernRequestsCarryTheEnvelope(): void } } - #[TestDox('a server that refuses "server/discover" still leaves a usable connection')] - public function testDiscoveryFailureIsNotFatal(): void + #[TestDox('a server that refuses "server/discover" is reached through the handshake instead')] + public function testRefusedProbeFallsBackToTheHandshake(): void { - $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true); + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, refuseDiscovery: true); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); - $protocol->initialize($config); + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $transport->methods); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $transport->offeredVersion); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); $this->assertTrue($protocol->getState()->isInitialized()); - $this->assertNull($protocol->getState()->getServerCapabilities()); + + // Nothing after the fallback carries the modern envelope. + $this->assertArrayNotHasKey(RequestMeta::PROTOCOL_VERSION, $transport->metas[1]); + } + + #[TestDox('the fallback offers the configured handshake revision')] + public function testFallbackOffersTheConfiguredRevision(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_06_18->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); + $this->assertSame(ProtocolVersion::V2025_06_18, $protocol->getState()->getProtocolVersion()); } - #[TestDox('refuses to continue when discovery shows the server has no modern revision')] - public function testDiscoveryWithoutAModernRevisionFails(): void + #[TestDox('a server that never answers the probe is reached through the handshake once it times out')] + public function testSilentProbeFallsBackToTheHandshake(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, ignoreDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + // The transport times a request out by resuming its fiber with an error; + // driven by hand here, the way StdioTransport::tick() would. + $fiber = new \Fiber(static fn () => $protocol->initialize($config)); + $suspended = $fiber->start(); + + $this->assertSame('await_response', $suspended['type']); + $fiber->resume(Error::forInternalError('Request timed out', $suspended['request_id'])); + + $this->assertTrue($fiber->isTerminated()); + $this->assertInstanceOf(Response::class, $fiber->getReturn()); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } + + #[TestDox('a refusal naming only handshake revisions falls back rather than failing')] + public function testRefusalNamingHandshakeRevisionsFallsBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryError: Error::forUnsupportedProtocolVersion('2026-07-28', ProtocolVersion::handshakeVersions())); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } + + #[TestDox('a refusal naming no revision this client speaks fails without a handshake')] + public function testRefusalNamingNothingUsableFails(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryError: new Error(1, Error::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', ['requested' => '2026-07-28', 'supported' => ['2099-01-01']])); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString('2099-01-01', $result->message); + $this->assertNotContains('initialize', $transport->methods); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + #[TestDox('a server advertising only handshake revisions is reached through the handshake')] + public function testDiscoveryWithoutAModernRevisionFallsBack(): 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'); + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $transport->methods); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } - $protocol->initialize($config); + #[TestDox('a modern-only client fails against a server without the modern era')] + public function testModernOnlyClientDoesNotFallBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, null)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString('without a handshake fallback', $result->message); + $this->assertSame(['server/discover'], $transport->methods); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + #[TestDox('a handshake refused because the server already settled on the modern era probes again')] + public function testLateModernSettlementIsProbedAgain(): void + { + // The first probe went unanswered in time; by the handshake, the server + // had answered it and settled on the modern era. + $transport = new RecordingTransport( + ProtocolVersion::V2025_11_25->value, + refuseDiscovery: true, + initializeError: Error::forUnsupportedProtocolVersion('2025-11-25', [ProtocolVersion::V2026_07_28]), + discoverAfterInitialize: true, + ); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'server/discover'], $transport->methods); + $this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); } #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] @@ -218,7 +313,8 @@ public function testEmptyInputResponsesEncodesAsJsonObject(): void { $transport = new InputRequiredRoundTripTransport(); $protocol = new Protocol(); - $protocol->connect($transport, $this->createConfiguration(ProtocolVersion::V2026_07_28)); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + $protocol->initialize($config); $result = $protocol->request(new PingRequest(), 5); @@ -392,12 +488,13 @@ private function waitPastDeadline(): void $this->assertGreaterThanOrEqual($boundary, microtime(true), 'The clock must pass the per-call deadline for this assertion to be about the deadline.'); } - private function createConfiguration(ProtocolVersion $protocolVersion): Configuration + private function createConfiguration(ProtocolVersion $protocolVersion, ?ProtocolVersion $fallback = ProtocolVersion::V2025_11_25): Configuration { return new Configuration( clientInfo: new Implementation('client-app', '1.0.0'), capabilities: new ClientCapabilities(), protocolVersion: $protocolVersion, + fallbackProtocolVersion: $fallback, ); } } @@ -421,10 +518,16 @@ public function setState(ClientStateInterface $state): void public function send(string $data): void { - /** @var array{id: int} $message */ + /** @var array{id: int, method: string} $message */ $message = json_decode($data, true); $id = $message['id']; + if ('server/discover' === $message['method']) { + $this->answer($id, ['resultType' => 'complete', 'supportedVersions' => [ProtocolVersion::V2026_07_28->value], 'capabilities' => []]); + + return; + } + if (0 === $this->calls++) { $this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]); @@ -495,9 +598,15 @@ final class RecordingTransport implements TransportInterface private ClientStateInterface $state; + private bool $initialized = false; + public function __construct( private readonly string $counterOffer, private readonly bool $refuseDiscovery = false, + private readonly bool $ignoreDiscovery = false, + private readonly ?Error $discoveryError = null, + private readonly ?Error $initializeError = null, + private readonly bool $discoverAfterInitialize = false, ) { } @@ -520,6 +629,13 @@ public function send(string $data): void if ('initialize' === $method) { $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + $this->initialized = true; + + if (null !== $this->initializeError) { + $this->fail($message['id'], $this->initializeError); + + return; + } $this->answer($message['id'], [ 'protocolVersion' => $this->counterOffer, @@ -534,19 +650,27 @@ public function send(string $data): void return; } - if ($this->refuseDiscovery) { - $this->state->storeResponse($message['id'], [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'error' => ['code' => -32601, 'message' => 'Method not found'], - ]); + if ($this->ignoreDiscovery) { + return; + } + + $discoverable = $this->discoverAfterInitialize && $this->initialized; + + if (null !== $this->discoveryError && !$discoverable) { + $this->fail($message['id'], $this->discoveryError); + + return; + } + + if ($this->refuseDiscovery && !$discoverable) { + $this->fail($message['id'], Error::forMethodNotFound('Method not found')); return; } $this->answer($message['id'], [ 'resultType' => 'complete', - 'supportedVersions' => [$this->counterOffer], + 'supportedVersions' => [$discoverable ? ProtocolVersion::V2026_07_28->value : $this->counterOffer], 'capabilities' => [], 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], ]); @@ -564,6 +688,11 @@ private function answer(int|string $id, array $result): void ]); } + private function fail(int|string $id, Error $error): void + { + $this->state->storeResponse($id, (new Error($id, $error->code, $error->message, $error->data))->jsonSerialize()); + } + public function setState(ClientStateInterface $state): void { $this->state = $state; From 238f856a7d8ea7361b8649d164781892a7bf18b3 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:45:01 +0200 Subject: [PATCH 02/23] [Client] Fail a request refused with an HTTP error status at once --- src/Client/Transport/HttpTransport.php | 41 +++++++ .../Client/Transport/HttpTransportTest.php | 109 ++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 53596bc90..823016849 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -205,6 +205,12 @@ public function send(string $data): void $contentType = strtolower($response->getHeaderLine('Content-Type')); + if ($response->getStatusCode() >= 400) { + $this->handleErrorStatus($data, $response->getStatusCode(), $response->getReasonPhrase(), $response->getBody()->getContents()); + + return; + } + if (str_contains($contentType, 'text/event-stream')) { // While listening, a request on the GET stream can be what this // response waits for, so neither stream may block the other. @@ -231,6 +237,41 @@ private static function isNotification(string $data): bool return \is_array($payload) && \array_key_exists('method', $payload) && !\array_key_exists('id', $payload); } + /** + * Answers a request the server refused at the HTTP level. + * + * A JSON-RPC error correlated with the request is handled like any other + * answer. Anything else — an empty or non-JSON body, or an error without + * the request's id, which is how a server from before the modern era + * typically refuses a request it did not expect — is turned into an error + * for that request, so the caller learns of it now rather than at its + * timeout. That is what a probe for the modern era relies on to fall back. + */ + private function handleErrorStatus(string $sent, int $status, string $reason, string $body): void + { + $request = json_decode($sent, true); + $requestId = \is_array($request) ? ($request['id'] ?? null) : null; + $answer = '' === trim($body) ? null : json_decode($body, true); + + if (\is_array($answer) && \array_key_exists('id', $answer) && null !== $answer['id']) { + $this->handleMessage($body); + + return; + } + + if ((!\is_string($requestId) && !\is_int($requestId)) || null === $this->state) { + $this->logger->warning('Server refused a message', ['status' => $status, 'body' => $body]); + + return; + } + + $error = \is_array($answer['error'] ?? null) && \is_int($answer['error']['code'] ?? null) + ? new Error($requestId, $answer['error']['code'], \is_string($answer['error']['message'] ?? null) ? $answer['error']['message'] : $reason, $answer['error']['data'] ?? null) + : Error::forInvalidRequest(\sprintf('Server answered with HTTP %d%s.', $status, '' !== $reason ? ' '.$reason : ''), $requestId); + + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + /** * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index c18aac490..2c21db5ca 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -15,6 +15,7 @@ use Mcp\Client\CancellationTokenInterface; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; @@ -87,8 +88,10 @@ public function sendRequest(RequestInterface $request): ResponseInterface } }; + // The handshake era is what these servers speak, so no probe precedes it. $client = Client::builder() ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->setInitTimeout(1) ->build(); @@ -131,8 +134,10 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertNull($transport->getSessionId()); + // The handshake era is what these servers speak, so no probe precedes it. $client = Client::builder() ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->setInitTimeout(1) ->build(); @@ -145,6 +150,110 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertNull($transport->getSessionId()); } + /** + * @return iterable, string}> + */ + public static function probeRefusalProvider(): iterable + { + yield 'a JSON-RPC error without an id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Bad Request: Server not initialized"}}']; + yield 'an empty body' => [400, [], '']; + yield 'a plain-text body' => [404, ['Content-Type' => 'text/plain'], 'Not Found']; + } + + /** + * @param array $headers + */ + #[DataProvider('probeRefusalProvider')] + #[TestDox('a handshake-era server refusing the probe with $_dataName is reached through the handshake at once')] + public function testRefusedProbeFallsBackWithoutWaiting(int $status, array $headers, string $body): void + { + $httpClient = new class($status, $headers, $body) implements ClientInterface { + /** @var list */ + public array $methods = []; + + /** + * @param array $headers + */ + public function __construct(private readonly int $status, private readonly array $headers, private readonly string $body) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + $this->methods[] = $decoded['method'] ?? ''; + + if ('initialize' !== ($decoded['method'] ?? null)) { + return 'server/discover' === ($decoded['method'] ?? null) + ? new Response($this->status, $this->headers, $this->body) + : new Response(202); + } + + return new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => new \stdClass(), + 'serverInfo' => ['name' => 'legacy-server', 'version' => '1.0.0'], + ], + ])); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setInitTimeout(5) + ->build(); + + $started = microtime(true); + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + + $this->assertLessThan(1, microtime(true) - $started, 'the refusal must not be waited out like silence'); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $httpClient->methods); + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + $this->assertSame('legacy-server', $client->getServerInfo()?->name); + } + + #[TestDox('a modern refusal under 400 is read as the modern error it is, not as a handshake-era server')] + public function testModernRefusalIsRead(): void + { + $httpClient = new class implements ClientInterface { + /** @var list */ + public array $methods = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + $this->methods[] = $decoded['method'] ?? ''; + + return new Response(400, ['Content-Type' => 'application/json'], (string) json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'error' => ['code' => -32022, 'message' => 'Unsupported protocol version', 'data' => ['requested' => '2026-07-28', 'supported' => ['2099-01-01']]], + ])); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setInitTimeout(5) + ->setMaxRetries(0) + ->build(); + + try { + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + $this->fail('A modern server sharing no revision with the client must fail the connection.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('2099-01-01', $e->getMessage()); + } + + // A modern server: no fallback to a handshake it does not have. + $this->assertSame(['server/discover'], $httpClient->methods); + } + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] public function testSseBufferIsBoundedByConfiguredCap(): void { From 9df57e2c8ff535b42b43227590aae0428ce41738 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:57:03 +0200 Subject: [PATCH 03/23] [Client] Fail at once when the stdio server process exits --- src/Client/Transport/StdioTransport.php | 14 ++++++++++- .../Client/Transport/StdioTransportTest.php | 25 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index c2be50e41..3dbb901eb 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -120,7 +120,12 @@ public function send(string $data): void throw new ConnectionException('Process stdin not available'); } - fwrite($this->stdin, $data."\n"); + // Silenced: a server that has exited is reported as the connection + // failure it is, not as a broken-pipe warning. + if (false === @fwrite($this->stdin, $data."\n")) { + throw new ConnectionException('Could not write to the server process; it is no longer running.'); + } + fflush($this->stdin); $this->logger->debug('Sent message to server', ['data' => $data]); @@ -270,6 +275,13 @@ private function processInput(): void $this->handleMessage($trimmed); } } + + // Everything it wrote has been read, and it will write nothing more: + // whatever is still pending can only time out, so fail it now. That a + // server went away is never an answer about which era it speaks. + if (\is_resource($this->stdout) && feof($this->stdout)) { + throw new ConnectionException('The server process closed its output; it is no longer running.'); + } } /** diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index ed7dc405e..7483a4660 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -11,8 +11,10 @@ namespace Mcp\Tests\Unit\Client\Transport; +use Mcp\Client; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\StdioTransport; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use PHPUnit\Framework\Attributes\TestDox; @@ -65,11 +67,32 @@ public function testWellFormedFramesStillParse(): void $this->setStdout($transport, $this->stream('{"a":1}'."\n".'{"b":2}'."\n")); - $this->invokeProcessInput($transport); + try { + $this->invokeProcessInput($transport); + } catch (ConnectionException) { + // The stream ends after these frames, which is reported once they + // have all been dispatched. + } $this->assertSame(['{"a":1}', '{"b":2}'], $messages); } + #[TestDox('a server that exits fails the connection at once instead of timing out')] + public function testExitedServerFailsTheConnection(): void + { + $client = Client::builder()->setInitTimeout(10)->setMaxRetries(0)->build(); + $started = microtime(true); + + try { + $client->connect(new StdioTransport(command: \PHP_BINARY, args: ['-r', 'exit(1);'])); + $this->fail('Connecting to a server that exits must fail.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('no longer running', $e->getMessage()); + } + + $this->assertLessThan(5, microtime(true) - $started); + } + #[TestDox('the buffer cap must be a positive number of bytes')] public function testRejectsNonPositiveCap(): void { From a25b03b7ece19eacb5e1bddd9f20517c04abf4dc Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:57:04 +0200 Subject: [PATCH 04/23] [Client] Adapt logging, ping and roots to a modern connection --- src/Client.php | 26 ++++++++++++++++-- src/Client/Protocol.php | 35 +++++++++++++++++++++++- src/Client/Stateless/RequestEnvelope.php | 21 +++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/Client.php b/src/Client.php index 66507ffe8..776a16624 100644 --- a/src/Client.php +++ b/src/Client.php @@ -31,6 +31,7 @@ use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Request\ListResourcesRequest; @@ -173,11 +174,14 @@ public function getProtocolVersion(): ?ProtocolVersion } /** - * Send a ping request to the server. + * Check that the server is reachable and answering. + * + * A `ping` on the handshake era. The modern era removed it, so there the + * check is a `server/discover`, which every modern server answers. */ public function ping(): void { - $request = new PingRequest(); + $request = $this->protocol->isModern() ? new DiscoverRequest() : new PingRequest(); $this->sendRequest($request); } @@ -337,9 +341,19 @@ public function complete(PromptReference|ResourceReference $ref, array $argument /** * Set the minimum logging level for server log messages. + * + * On the handshake era this is a `logging/setLevel` request. The modern + * era removed it: there the level rides on every request that follows, + * and until one is set the server sends no log messages at all. */ public function setLoggingLevel(LoggingLevel $level): void { + if ($this->protocol->isModern()) { + $this->protocol->setLogLevel($level); + + return; + } + $request = new SetLogLevelRequest($level); $this->sendRequest($request); @@ -363,6 +377,14 @@ public function sendRootsListChanged(): void throw new ConnectionException('Client is not connected. Call connect() first.'); } + // The modern era removed roots, so a server on it has nothing to + // refresh — and nothing to tell. + if ($this->protocol->isModern()) { + $this->logger->debug('Not sending "notifications/roots/list_changed": the connection is on the modern era, which removed roots.'); + + return; + } + $this->protocol->sendNotification(new RootsListChangedNotification()); } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 8a1a6fa01..80a6c1763 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -26,6 +26,7 @@ use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -79,6 +80,9 @@ class Protocol private ToolCatalog $tools; + /** What a modern-era connection stamps on every request, see {@see self::setLogLevel()}. */ + private ?LoggingLevel $logLevel = null; + private readonly InputRequestResolver $inputRequests; /** @@ -261,10 +265,29 @@ private function negotiate(Configuration $config): Response|Error private function enterModernEra(ProtocolVersion $version, Configuration $config): void { - $this->envelope = new RequestEnvelope($version, $config->capabilities, $config->clientInfo); + $this->envelope = new RequestEnvelope($version, $config->capabilities, $config->clientInfo, $this->logLevel); $this->headers = new HeaderFactory($this->tools); } + /** + * Whether the connection settled on the modern era, where every request + * carries its own metadata. + */ + public function isModern(): bool + { + return null !== $this->envelope; + } + + /** + * Ask for the server's log messages from $level up on every request that + * follows — the modern era's stand-in for `logging/setLevel`. + */ + public function setLogLevel(LoggingLevel $level): void + { + $this->logLevel = $level; + $this->envelope = $this->envelope?->withLogLevel($level); + } + /** * Reads a probe's answer: the connection, now modern; an error ending the * attempt; or null when the server is not a modern one. @@ -381,6 +404,16 @@ private function handshake(ProtocolVersion $offered, Configuration $config): Res $response = $this->request($request, $config->initTimeout); + if ($response instanceof Error && Error::UNSUPPORTED_PROTOCOL_VERSION === $response->code) { + $named = \is_array($response->data['supported'] ?? null) ? array_filter($response->data['supported'], is_string(...)) : []; + + return new Error($response->id, $response->code, \sprintf( + 'Server does not speak protocol version %s; it supports %s.', + $offered->value, + [] === $named ? 'none it named' : implode(', ', $named), + ), $response->data); + } + if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php index fcc1cac4b..f2ebce2ac 100644 --- a/src/Client/Stateless/RequestEnvelope.php +++ b/src/Client/Stateless/RequestEnvelope.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Stateless; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Server\Stateless\RequestMeta; @@ -31,10 +32,15 @@ */ final class RequestEnvelope { + /** + * @param LoggingLevel|null $logLevel the least severe log message the client wants to hear about while a + * request runs; null asks for none, which is what this revision assumes + */ public function __construct( private readonly ProtocolVersion $protocolVersion, private readonly ClientCapabilities $capabilities, private readonly Implementation $clientInfo, + private readonly ?LoggingLevel $logLevel = null, ) { } @@ -45,7 +51,16 @@ public function protocolVersion(): ProtocolVersion public function withProtocolVersion(ProtocolVersion $protocolVersion): self { - return new self($protocolVersion, $this->capabilities, $this->clientInfo); + return new self($protocolVersion, $this->capabilities, $this->clientInfo, $this->logLevel); + } + + /** + * The per-request replacement for `logging/setLevel`, which this revision + * removed: the level now rides on every request instead of being set once. + */ + public function withLogLevel(?LoggingLevel $logLevel): self + { + return new self($this->protocolVersion, $this->capabilities, $this->clientInfo, $logLevel); } /** @@ -71,6 +86,10 @@ public function stamp(array $payload): array RequestMeta::CLIENT_INFO => $this->clientInfo, ]; + if (null !== $this->logLevel) { + $params['_meta'][RequestMeta::LOG_LEVEL] = $this->logLevel->value; + } + $payload['params'] = $params; return $payload; From 1ab9eadd68279c0e89425952fc81e15b44d2907d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:03:18 +0200 Subject: [PATCH 05/23] [Client] Fail requests to an exited stdio server as answers --- src/Client/Transport/StdioTransport.php | 29 +++++++++++-------- .../Client/Transport/StdioTransportTest.php | 24 +++++++++++---- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 3dbb901eb..cb3f37622 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -277,10 +277,23 @@ private function processInput(): void } // Everything it wrote has been read, and it will write nothing more: - // whatever is still pending can only time out, so fail it now. That a - // server went away is never an answer about which era it speaks. + // whatever is still pending can only time out, so fail it now. Failed + // as answers rather than thrown, so each waiting fiber unwinds and + // clears its request instead of leaving it to time out a later one. if (\is_resource($this->stdout) && feof($this->stdout)) { - throw new ConnectionException('The server process closed its output; it is no longer running.'); + $this->failPending('The server process closed its output; it is no longer running.'); + } + } + + private function failPending(string $reason): void + { + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $this->state->storeResponse($requestId, Error::forInternalError($reason, $requestId)->jsonSerialize()); } } @@ -300,15 +313,7 @@ private function abortInput(string $reason): void 'max_buffer_size' => $this->maxBufferSize, ]); - if (null === $this->state) { - return; - } - - foreach ($this->state->getPendingRequests() as $pending) { - $requestId = $pending['request_id']; - $error = Error::forInternalError('stdio input aborted: '.$reason, $requestId); - $this->state->storeResponse($requestId, $error->jsonSerialize()); - } + $this->failPending('stdio input aborted: '.$reason); } private function processFiber(): void diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index 7483a4660..983c31ab2 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -67,16 +67,28 @@ public function testWellFormedFramesStillParse(): void $this->setStdout($transport, $this->stream('{"a":1}'."\n".'{"b":2}'."\n")); - try { - $this->invokeProcessInput($transport); - } catch (ConnectionException) { - // The stream ends after these frames, which is reported once they - // have all been dispatched. - } + $this->invokeProcessInput($transport); $this->assertSame(['{"a":1}', '{"b":2}'], $messages); } + #[TestDox('a server closing its output fails what is pending as answers, so nothing is left to time out later')] + public function testClosedOutputFailsPendingRequests(): void + { + $transport = new StdioTransport(command: 'true'); + $state = new ClientState(); + $transport->setState($state); + $state->addPendingRequest(1, 120); + + $this->setStdout($transport, $this->stream('')); + $this->invokeProcessInput($transport); + + $response = $state->consumeResponse(1); + + $this->assertInstanceOf(Error::class, $response); + $this->assertStringContainsString('no longer running', $response->message); + } + #[TestDox('a server that exits fails the connection at once instead of timing out')] public function testExitedServerFailsTheConnection(): void { From 39a1d2a44d9619b21cf5ac63d0d2a67b93471065 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:05:11 +0200 Subject: [PATCH 06/23] [Client] Require a connection to set the log level on a modern connection --- src/Client.php | 4 ++++ tests/Unit/ClientTest.php | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 776a16624..22e862823 100644 --- a/src/Client.php +++ b/src/Client.php @@ -348,6 +348,10 @@ public function complete(PromptReference|ResourceReference $ref, array $argument */ public function setLoggingLevel(LoggingLevel $level): void { + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + if ($this->protocol->isModern()) { $this->protocol->setLogLevel($level); diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 3ecdb0026..424614908 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -17,6 +17,7 @@ use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -55,6 +56,19 @@ public function testConnectSucceedsWithoutRetrying(): void $this->assertTrue($client->isConnected()); } + #[TestDox('setting the log level after disconnecting a modern connection fails like any other call')] + public function testSetLoggingLevelRequiresAConnection(): void + { + $client = Client::builder()->setProtocolVersion(ProtocolVersion::V2026_07_28)->build(); + $client->connect(new FakeTransport([FakeTransport::ACCEPT_MODERN])); + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + $client->disconnect(); + + $this->expectException(ConnectionException::class); + + $client->setLoggingLevel(LoggingLevel::Info); + } + #[TestDox('connect() retries a failed attempt and succeeds on a later one')] public function testConnectRetriesUntilItSucceeds(): void { @@ -171,6 +185,9 @@ final class FakeTransport extends BaseTransport /** The initialize request is answered with a result. */ public const ACCEPT = 'accept'; + /** The `server/discover` probe is answered as a modern server would, settling the connection on 2026-07-28. */ + public const ACCEPT_MODERN = 'accept_modern'; + /** The initialize request is answered with a JSON-RPC error. */ public const REJECT = 'reject'; @@ -189,7 +206,7 @@ final class FakeTransport extends BaseTransport private array $outbox = []; /** - * @param list $attempts How each successive connect() call behaves + * @param list $attempts How each successive connect() call behaves */ public function __construct(private array $attempts = [self::ACCEPT]) { @@ -224,6 +241,16 @@ public function send(string $data): void return; // A notification, nothing to answer. } + if (self::ACCEPT_MODERN === $this->outcome && 'server/discover' === ($message['method'] ?? null)) { + $this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id'], 'result' => [ + 'resultType' => 'complete', + 'supportedVersions' => [ProtocolVersion::V2026_07_28->value], + 'capabilities' => [], + ]], \JSON_THROW_ON_ERROR); + + return; + } + $answer = self::REJECT === $this->outcome ? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']] : ['result' => [ From 5bf6611fae329a72c9b61554af3568638aae0fb8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:05:12 +0200 Subject: [PATCH 07/23] [Client] Correlate an HTTP refusal only by the request's own id --- src/Client/Transport/HttpTransport.php | 6 +++--- tests/Unit/Client/Transport/HttpTransportTest.php | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 823016849..70e1e41a1 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -240,9 +240,9 @@ private static function isNotification(string $data): bool /** * Answers a request the server refused at the HTTP level. * - * A JSON-RPC error correlated with the request is handled like any other + * A JSON-RPC error carrying the request's id is handled like any other * answer. Anything else — an empty or non-JSON body, or an error without - * the request's id, which is how a server from before the modern era + * that id, which is how a server from before the modern era * typically refuses a request it did not expect — is turned into an error * for that request, so the caller learns of it now rather than at its * timeout. That is what a probe for the modern era relies on to fall back. @@ -253,7 +253,7 @@ private function handleErrorStatus(string $sent, int $status, string $reason, st $requestId = \is_array($request) ? ($request['id'] ?? null) : null; $answer = '' === trim($body) ? null : json_decode($body, true); - if (\is_array($answer) && \array_key_exists('id', $answer) && null !== $answer['id']) { + if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId) { $this->handleMessage($body); return; diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 2c21db5ca..5f3d3a6ee 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -156,6 +156,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface public static function probeRefusalProvider(): iterable { yield 'a JSON-RPC error without an id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Bad Request: Server not initialized"}}']; + yield 'a JSON-RPC error under another id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":999,"error":{"code":-32600,"message":"Bad Request"}}']; yield 'an empty body' => [400, [], '']; yield 'a plain-text body' => [404, ['Content-Type' => 'text/plain'], 'Not Found']; } From 0efa58cf387a5550f6899c79e04839839d03349a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:15:52 +0200 Subject: [PATCH 08/23] [Tests] Cover version negotiation end to end over stdio and HTTP --- tests/Integration/ElicitationTest.php | 24 ++- tests/Integration/Fixture/handshake.php | 5 + tests/Integration/Fixture/http.php | 46 ++++++ tests/Integration/HandshakeTest.php | 93 +++++++++--- tests/Integration/HttpNegotiationTest.php | 171 ++++++++++++++++++++++ tests/Integration/IntegrationTestCase.php | 13 ++ tests/Integration/NotificationTest.php | 24 ++- tests/Integration/SamplingTest.php | 17 +++ 8 files changed, 369 insertions(+), 24 deletions(-) create mode 100644 tests/Integration/Fixture/http.php create mode 100644 tests/Integration/HttpNegotiationTest.php diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php index dc1684796..66753cb09 100644 --- a/tests/Integration/ElicitationTest.php +++ b/tests/Integration/ElicitationTest.php @@ -14,9 +14,11 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\ElicitationCallbackInterface; use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Exception\RuntimeException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\ElicitResult; use PHPUnit\Framework\Attributes\TestDox; @@ -75,7 +77,9 @@ public function testCapabilityIsVisibleToTheServer(): void public function testAdvertisedCapabilityWithoutHandler(): void { // The client answers "method not found", which the gateway raises inside - // the tool as a ClientException rather than leaving it waiting. + // the tool as a ClientException rather than leaving it waiting. That is + // a handshake-era exchange: the modern era has no way to answer an ask + // with an error, see below. $client = $this->connect( 'elicitation', $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), @@ -87,6 +91,24 @@ public function testAdvertisedCapabilityWithoutHandler(): void $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); } + #[TestDox('on the modern era, an ask the client advertised but cannot answer fails the call on the client')] + public function testAdvertisedCapabilityWithoutHandlerOnTheModernEra(): void + { + $client = $this->connect( + 'elicitation', + $this->clientBuilder() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)), + ); + + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Client does not handle "elicitation/create" requests.'); + + $client->callTool('ask_name'); + } + private function clientAnswering(ElicitResult $answer): ClientBuilder { $callback = new class($answer) implements ElicitationCallbackInterface { diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php index 776f45131..0760289b9 100644 --- a/tests/Integration/Fixture/handshake.php +++ b/tests/Integration/Fixture/handshake.php @@ -13,6 +13,7 @@ * Server for {@see \Mcp\Tests\Integration\HandshakeTest}. * * The test pins the revision through the environment; unset negotiates freely. + * MCP_INTEGRATION_HANDSHAKE_ONLY makes it a server from before the modern era. */ use Mcp\Schema\Enum\ProtocolVersion; @@ -29,4 +30,8 @@ $builder->setProtocolVersion(ProtocolVersion::from($pinned)); } +if ('' !== (string) getenv('MCP_INTEGRATION_HANDSHAKE_ONLY')) { + $builder->withoutModernEra(); +} + $builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/http.php b/tests/Integration/Fixture/http.php new file mode 100644 index 000000000..6b9d31740 --- /dev/null +++ b/tests/Integration/Fixture/http.php @@ -0,0 +1,46 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.') + // Nothing survives between requests under `php -S`, so the handshake era + // keeps its sessions on disk. + ->setSession(new FileSessionStore((string) getenv('MCP_INTEGRATION_SESSIONS'))) + ->addTool(static fn (string $text): string => $text, name: 'echo', description: 'Echoes the text back.'); + +if ('' !== (string) getenv('MCP_INTEGRATION_HANDSHAKE_ONLY')) { + $builder->withoutModernEra(); +} + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = '' !== (string) getenv('MCP_INTEGRATION_MODERN_ONLY') + ? (new StatelessHttpTransport($builder->buildStateless()))->handle($request) + : $builder->build()->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 7cca7c8f7..1cd9f1e15 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Integration; +use Mcp\Exception\ConnectionException; use Mcp\Schema\Enum\ProtocolVersion; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -24,30 +25,29 @@ final class HandshakeTest extends IntegrationTestCase { #[TestDox('client and server agree on a revision')] #[DataProvider('provideNegotiations')] - public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?ProtocolVersion $serverVersion, ProtocolVersion $expected): void + public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?ProtocolVersion $serverVersion, ProtocolVersion $expected, bool $handshakeOnly = false, ?ProtocolVersion $fallback = null): void { $client = $this->clientBuilder(); + if (null !== $clientVersion) { $client->setProtocolVersion($clientVersion); } - $connected = $this->connect( - 'handshake', - $client, - null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], - ); + if (null !== $fallback) { + $client->setFallbackProtocolVersion($fallback); + } + + $connected = $this->connect('handshake', $client, self::environment($serverVersion, $handshakeOnly)); $this->assertSame($expected, $connected->getProtocolVersion()); } /** - * @return iterable + * @return iterable */ public static function provideNegotiations(): iterable { - $latest = ProtocolVersion::latestHandshake(); - - yield 'both unconfigured' => [null, null, $latest]; + yield 'both unconfigured' => [null, null, ProtocolVersion::latestHandshake()]; // Whichever end of the supported range it sits at. foreach (ProtocolVersion::handshakeVersions() as $version) { @@ -59,23 +59,53 @@ public static function provideNegotiations(): iterable 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]; - // 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]; + + // A server from before the modern era refuses the probe, and the + // client takes the handshake instead. + yield 'server without the modern era' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_11_25, true]; + yield 'server without the modern era, client falling back further' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_06_18, true, ProtocolVersion::V2025_06_18]; + yield 'server without the modern era pinning a revision' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26, true]; } - #[TestDox('the handshake carries the server identity to the client')] - public function testServerInfoIsExchanged(): void + #[TestDox('falling back to the handshake costs a refusal, not a timeout')] + public function testFallbackDoesNotWaitOutTheProbe(): void { - $client = $this->connect('handshake'); + $started = microtime(true); + $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28), self::environment(null, true)); + + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + // Well under the five seconds the probe would otherwise be waited for. + $this->assertLessThan(3, microtime(true) - $started); + } + #[TestDox('a modern-only client refuses a server without the modern era')] + public function testModernOnlyClientRefusesAHandshakeOnlyServer(): void + { + $client = $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28)->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); + + try { + $client->connect($this->transport('handshake', self::environment(null, true))); + $this->fail('A modern-only client must not connect to a server without the modern era.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('without a handshake fallback', $e->getMessage()); + } finally { + $client->disconnect(); + } + } + + #[TestDox('the server identity reaches the client on $_dataName')] + #[DataProvider('provideEras')] + public function testServerInfoIsExchanged(ProtocolVersion $era): void + { + $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion($era)); + + $this->assertSame($era, $client->getProtocolVersion()); $serverInfo = $client->getServerInfo(); $this->assertNotNull($serverInfo); $this->assertSame('integration-server', $serverInfo->name); @@ -84,6 +114,35 @@ public function testServerInfoIsExchanged(): void $this->assertTrue($client->isConnected()); } + #[TestDox('a liveness check works on $_dataName')] + #[DataProvider('provideEras')] + public function testPing(ProtocolVersion $era): void + { + $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion($era)); + + $client->ping(); + + $this->assertTrue($client->isConnected()); + } + + /** + * @return array + */ + private static function environment(?ProtocolVersion $serverVersion, bool $handshakeOnly): array + { + $env = []; + + if (null !== $serverVersion) { + $env['MCP_INTEGRATION_PROTOCOL_VERSION'] = $serverVersion->value; + } + + if ($handshakeOnly) { + $env['MCP_INTEGRATION_HANDSHAKE_ONLY'] = '1'; + } + + return $env; + } + #[TestDox('the handshake carries the server capabilities to the client')] public function testServerCapabilitiesAreExchanged(): void { diff --git a/tests/Integration/HttpNegotiationTest.php b/tests/Integration/HttpNegotiationTest.php new file mode 100644 index 000000000..bb37e8a65 --- /dev/null +++ b/tests/Integration/HttpNegotiationTest.php @@ -0,0 +1,171 @@ +sessions = sys_get_temp_dir().'/mcp-integration-sessions-'.getmypid(); + $this->port = 9600 + (getmypid() % 200); + } + + protected function tearDown(): void + { + $this->server?->stop(); + + foreach (glob($this->sessions.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->sessions); + } + + /** + * @return iterable + */ + public static function provideNegotiations(): iterable + { + yield 'a client and a server speaking both eras' => [self::BOTH, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28]; + yield 'a handshake-era client and a server speaking both eras' => [self::BOTH, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; + yield 'a client speaking both eras and a server without the modern era' => [self::HANDSHAKE_ONLY, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_11_25]; + yield 'a handshake-era client and a server without the modern era' => [self::HANDSHAKE_ONLY, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; + yield 'a client speaking both eras and a server with only the modern era' => [self::MODERN_ONLY, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28]; + } + + #[DataProvider('provideNegotiations')] + #[TestDox('$_dataName settle on a revision and talk')] + public function testNegotiatesAndTalks(string $server, ?ProtocolVersion $clientVersion, ProtocolVersion $expected): void + { + $this->start($server); + + $builder = $this->clientBuilder(); + + if (null !== $clientVersion) { + $builder->setProtocolVersion($clientVersion); + } + + $client = $builder->build(); + $started = microtime(true); + $client->connect($this->transport()); + + // A refused probe is answered at once; only silence would cost the timeout. + $this->assertLessThan(self::TIMEOUT - 1, microtime(true) - $started); + $this->assertSame($expected, $client->getProtocolVersion()); + $this->assertSame('integration-server', $client->getServerInfo()?->name); + $this->assertSame('Be brief.', $client->getInstructions()); + + $result = $client->callTool('echo', ['text' => 'hello']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('hello', $result->content[0]->text); + + $client->disconnect(); + } + + #[TestDox('a handshake-era client is told which revisions a server with only the modern era speaks')] + public function testHandshakeClientLearnsWhatAModernOnlyServerSpeaks(): void + { + $this->start(self::MODERN_ONLY); + + $client = $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25)->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('it supports 2026-07-28'); + + $client->connect($this->transport()); + } + + #[TestDox('a modern-only client refuses a server without the modern era')] + public function testModernOnlyClientRefusesAHandshakeOnlyServer(): void + { + $this->start(self::HANDSHAKE_ONLY); + + $client = $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28)->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('without a handshake fallback'); + + $client->connect($this->transport()); + } + + private function clientBuilder(): ClientBuilder + { + return Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT); + } + + private function transport(): HttpTransport + { + return new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)); + } + + private function start(string $server): void + { + @mkdir($this->sessions); + + $this->server = new Process( + [\PHP_BINARY, '-S', \sprintf('127.0.0.1:%d', $this->port), __DIR__.'/Fixture/http.php'], + env: [ + 'MCP_INTEGRATION_SESSIONS' => $this->sessions, + 'MCP_INTEGRATION_HANDSHAKE_ONLY' => self::HANDSHAKE_ONLY === $server ? '1' : '', + 'MCP_INTEGRATION_MODERN_ONLY' => self::MODERN_ONLY === $server ? '1' : '', + ], + ); + $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 fixture server did not start: %s', $this->server->getErrorOutput())); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 4ad33d422..e057d4b63 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -15,6 +15,7 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Transport\StdioTransport; use Mcp\Exception\ConnectionException; +use Mcp\Schema\Enum\ProtocolVersion; use PHPUnit\Framework\TestCase; /** @@ -45,6 +46,18 @@ protected function clientBuilder(): ClientBuilder ->setRequestTimeout(self::TIMEOUT); } + /** + * Both eras one fixture server serves, for behaviour a caller should see + * the same on either. + * + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + } + /** * Spawn a fixture server and connect a client to it. * diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php index 09487185a..76d4b2fbd 100644 --- a/tests/Integration/NotificationTest.php +++ b/tests/Integration/NotificationTest.php @@ -14,7 +14,9 @@ use Mcp\Client\Handler\Notification\LoggingNotificationHandler; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Notification\LoggingMessageNotification; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; /** @@ -28,9 +30,11 @@ final class NotificationTest extends IntegrationTestCase { #[TestDox('progress notifications reach the callback passed to callTool()')] - public function testProgressReachesTheCaller(): void + #[DataProvider('provideEras')] + public function testProgressReachesTheCaller(ProtocolVersion $era): void { - $client = $this->connect('notification'); + $client = $this->connect('notification', $this->clientBuilder()->setProtocolVersion($era)); + $this->assertSame($era, $client->getProtocolVersion()); $updates = []; $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { @@ -43,11 +47,12 @@ public function testProgressReachesTheCaller(): void } #[TestDox('progress is skipped when the caller asked for none')] - public function testProgressIsSkippedWithoutAToken(): void + #[DataProvider('provideEras')] + public function testProgressIsSkippedWithoutAToken(ProtocolVersion $era): void { // Without an onProgress callback the request carries no progress token, // so the gateway drops the notification instead of sending it. - $client = $this->connect('notification'); + $client = $this->connect('notification', $this->clientBuilder()->setProtocolVersion($era)); $result = $client->callTool('work'); @@ -56,18 +61,25 @@ public function testProgressIsSkippedWithoutAToken(): void } #[TestDox('log notifications reach a registered logging handler')] - public function testLoggingReachesTheClient(): void + #[DataProvider('provideEras')] + public function testLoggingReachesTheClient(ProtocolVersion $era): void { $logged = []; $client = $this->connect( 'notification', - $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( + $this->clientBuilder()->setProtocolVersion($era)->addNotificationHandler(new LoggingNotificationHandler( static function (LoggingMessageNotification $notification) use (&$logged): void { $logged[] = [$notification->level, $notification->data]; }, )), ); + $this->assertSame($era, $client->getProtocolVersion()); + + // A request on the handshake era, a level carried by every request + // that follows on the modern one — the caller cannot tell. + $client->setLoggingLevel(LoggingLevel::Info); + $client->callTool('work'); $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index 9de36bf7a..2b61acb05 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -14,8 +14,10 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\SamplingCallbackInterface; use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Exception\RequestException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Enum\Role; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; @@ -98,6 +100,21 @@ public function testClientWithoutSamplingRefuses(): void $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); } + #[TestDox('a tool that samples tells a client on the modern era it cannot, rather than hanging')] + public function testSamplingIsUnavailableOnTheModernEra(): void + { + $client = $this->connect('sampling', $this->clientSampling()->setProtocolVersion(ProtocolVersion::V2026_07_28)); + + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + + try { + $client->callTool('summarize', ['text' => 'hello']); + $this->fail('Sampling must not be reachable on 2026-07-28, which removed it.'); + } catch (RequestException $e) { + $this->assertStringContainsString('sampling and roots were removed', $e->getMessage()); + } + } + /** * @param \ArrayObject|null $seen collects what the server asked for */ From 98ba3249bb76bfd96ae03756e1f1497b01e3b752 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:16:17 +0200 Subject: [PATCH 09/23] [Docs] Document client version negotiation --- CHANGELOG.md | 3 +++ docs/client/connecting.md | 35 ++++++++++++++++++-------- docs/protocol-versions.md | 53 ++++++++++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ee7a5cc..ad49825c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * Serve both protocol eras over stdio: `StdioTransport` settles the era on the client's first request and serves `2026-07-28` requests, `subscriptions/listen` and `notifications/cancelled` on the one channel. * [BC Break] `StatelessAwareTransportInterface` declares `setHandshakeVersions()`, so a server without the modern era names only the revisions it negotiates when refusing a `2026-07-28` request, e.g. the one set with `Builder::setProtocolVersion()`. * Answer a bare `initialize` on a `2026-07-28`-only endpoint with `-32022` naming the served revisions, and a request without a session on the handshake leg with its id. +* Speak both protocol eras from a client configured with `2026-07-28`: `connect()` probes with `server/discover` and falls back to the `initialize` handshake on `2025-11-25` when the server does not speak the modern era. `Builder::setFallbackProtocolVersion()` picks the fallback revision, or `null` for a modern-only client. +* On a `2026-07-28` connection, `Client::setLoggingLevel()` stamps the level on every following request, `Client::ping()` sends `server/discover` and `Client::sendRootsListChanged()` sends nothing. +* Fail a client request at once when the HTTP server refuses it with an error status or the stdio server process exits, instead of waiting out the timeout. 0.8.0 ----- diff --git a/docs/client/connecting.md b/docs/client/connecting.md index eef0bc7ac..d8ca4fca1 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -61,8 +61,7 @@ $client = Client::builder() ### Protocol Version -Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the -latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): +Specify the MCP protocol version to speak (defaults to `V2025_11_25`). A handshake revision opens with `initialize`: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -72,19 +71,33 @@ $client = Client::builder() ->build(); ``` -This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as -described in the specification's +A modern revision makes the client speak both protocol eras: it probes for `2026-07-28` with `server/discover` when +connecting, and falls back to the `initialize` handshake on `2025-11-25` when the server turns out not to speak it. +Use `$client->getProtocolVersion()` after connecting to read what the connection settled on. + +```php +// Fall back to an older handshake revision instead of 2025-11-25… +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setFallbackProtocolVersion(ProtocolVersion::V2025_06_18) + ->build(); + +// …or not at all, refusing servers without the modern era. +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setFallbackProtocolVersion(null) + ->build(); +``` + +The handshake is an offer, not a demand. A server that does not support the requested revision counter-offers one it +does, as described in the specification's [protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed -on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. - -Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no -`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing -else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. +on. -See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the -exchange. +See [Protocol versions](../protocol-versions.md#how-the-client-settles-on-an-era) for how the probe is read, and +[the handshake era](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the exchange. ### Capabilities diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 4dec7b83e..7d4f2b86e 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -104,7 +104,9 @@ either lifecycle. What changes: ## Speaking it from a client -One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. +Ask for `2026-07-28` and the client speaks both eras: it finds out on `connect()` whether +the server does too, and nothing about the [client API](client/index.md) depends on the +answer. ```php $client = Client::builder() @@ -116,16 +118,49 @@ $client = Client::builder() $client->connect(new HttpTransport('https://example.com/mcp')); +$client->getProtocolVersion(); // 2026-07-28, or 2025-11-25 against an older server $client->callTool('greet', []); ``` -What that changes underneath: +### How the client settles on an era -- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the - server's identity, and a server that does not answer it still yields a usable connection — - the method is optional. If discovery *does* report `supportedVersions` and the configured - revision is not among them, the client moves to a modern revision the server lists, or - refuses the connection outright rather than talking past it. +`connect()` probes with `server/discover`, stamped with the preferred revision, before +anything else — as the specification's backward-compatibility rules for +[stdio](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility) +and +[Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility) +describe. + +| The probe gets | The client | +| --- | --- | +| a `DiscoverResult` listing a modern revision it speaks | stays modern, on that revision | +| `-32022` naming a modern revision it speaks | retries the probe with that revision | +| `-32022` naming only handshake revisions | falls back to `initialize` | +| `-32022` naming nothing it speaks | fails the connection | +| any other error, an HTTP refusal without one, or no answer in time | falls back to `initialize` | +| a `DiscoverResult` listing only handshake revisions | falls back to `initialize` | + +The fallback is not keyed to one error code: servers from before the modern era refuse an +unexpected request however they like, or not at all. A refusal costs nothing — the client +falls back as soon as it arrives — but a server that stays silent costs the +[initialization timeout](client/connecting.md#basic-configuration). A server process that exits fails the +attempt outright: an outage is not an answer about the era. + +The fallback offers `2025-11-25`; `setFallbackProtocolVersion()` picks another handshake +revision, and `setFallbackProtocolVersion(null)` makes the client modern-only, failing the +connection instead. A handshake revision passed to `setProtocolVersion()` skips the probe +and opens with `initialize`, as a client from before the modern era would. + +Once modern, a handful of calls change shape under the same API: `setLoggingLevel()` rides on +every following request instead of sending the removed `logging/setLevel`, `ping()` becomes a +`server/discover`, and `sendRootsListChanged()` sends nothing, since roots are gone. Sampling +and roots are handshake-era features: a server asking for them on a modern connection fails the +call instead. + +What being modern changes underneath: + +- **No handshake.** `connect()` sends no `initialize`; the probe's `DiscoverResult` is what + fills in the server's identity and instructions. - **An envelope on every request**, carrying the revision, the declared capabilities and the client identity. The capabilities are what let a server decide, per request, whether it may ask for input. @@ -142,8 +177,8 @@ What that changes underneath: Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry -them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies -to both. +them on. Everything else — the probe, the envelope, the skipped handshake, the round-trip +loop — applies to both. See [`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) From 8b132373363c976ea40d1aba109bedc57eb33fc7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:51:11 +0200 Subject: [PATCH 10/23] [Client] Require a probe answer to name its revisions before settling on the modern era --- src/Client/Protocol.php | 16 +++++---------- tests/Unit/Client/ProtocolTest.php | 31 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 80a6c1763..8e484ac10 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -226,7 +226,7 @@ private function negotiate(Configuration $config): Response|Error $this->enterModernEra($version, $config); $probe = $this->request(new DiscoverRequest(), $config->initTimeout); - $adopted = $this->adopt($probe, $config); + $adopted = $this->adopt($probe); if ($adopted instanceof Response || $adopted instanceof Error) { return $adopted; @@ -296,7 +296,7 @@ public function setLogLevel(LoggingLevel $level): void * * @return Response>|Error|null */ - private function adopt(Response|Error $probe, Configuration $config): Response|Error|null + private function adopt(Response|Error $probe): Response|Error|null { \assert(null !== $this->envelope); @@ -324,16 +324,10 @@ private function adopt(Response|Error $probe, Configuration $config): Response|E $advertised = $probe->result['supportedVersions'] ?? null; + // Not a DiscoverResult, which has to name its revisions, so not evidence + // of the modern era either. if (!\is_array($advertised)) { - // Not a DiscoverResult, so not evidence of the modern era. A client - // with nowhere else to go keeps the revision it was configured with. - if (null !== $config->fallbackProtocolVersion) { - return null; - } - - $this->readDiscovery($probe->result); - - return $this->settleModern($probe); + return null; } $current = $this->envelope->protocolVersion(); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index ae3af89ae..1d984626f 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -187,6 +187,30 @@ public function testModernOnlyClientDoesNotFallBack(): void $this->assertFalse($protocol->getState()->isInitialized()); } + /** + * @return iterable + */ + public static function provideFallbacks(): iterable + { + yield 'with a fallback' => [ProtocolVersion::V2025_11_25, true]; + yield 'modern-only' => [null, false]; + } + + #[TestDox('an answer to the probe that names no revision is no evidence of the modern era ($_dataName)')] + #[DataProvider('provideFallbacks')] + public function testDiscoveryWithoutRevisionsIsNotModernEvidence(?ProtocolVersion $fallback, bool $connects): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryWithoutVersions: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, $fallback)); + + $result = $protocol->initialize($config); + + $this->assertNotSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); + $this->assertSame($connects, $result instanceof Response); + $this->assertSame($connects, $protocol->getState()->isInitialized()); + } + #[TestDox('a handshake refused because the server already settled on the modern era probes again')] public function testLateModernSettlementIsProbedAgain(): void { @@ -607,6 +631,7 @@ public function __construct( private readonly ?Error $discoveryError = null, private readonly ?Error $initializeError = null, private readonly bool $discoverAfterInitialize = false, + private readonly bool $discoveryWithoutVersions = false, ) { } @@ -668,6 +693,12 @@ public function send(string $data): void return; } + if ($this->discoveryWithoutVersions) { + $this->answer($message['id'], []); + + return; + } + $this->answer($message['id'], [ 'resultType' => 'complete', 'supportedVersions' => [$discoverable ? ProtocolVersion::V2026_07_28->value : $this->counterOffer], From dfbf1f7d952f624919f85cc6b18ff0b2c466009f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:51:42 +0200 Subject: [PATCH 11/23] [Client] Fail an HTTP refusal at once when its body is no well-formed answer --- src/Client/Transport/HttpTransport.php | 25 ++++++++++++++++--- .../Client/Transport/HttpTransportTest.php | 2 ++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 70e1e41a1..37b59911c 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -240,9 +240,9 @@ private static function isNotification(string $data): bool /** * Answers a request the server refused at the HTTP level. * - * A JSON-RPC error carrying the request's id is handled like any other - * answer. Anything else — an empty or non-JSON body, or an error without - * that id, which is how a server from before the modern era + * A well-formed JSON-RPC answer carrying the request's id is handled like + * any other. Anything else — an empty, non-JSON or malformed body, or an + * error without that id, which is how a server from before the modern era * typically refuses a request it did not expect — is turned into an error * for that request, so the caller learns of it now rather than at its * timeout. That is what a probe for the modern era relies on to fall back. @@ -253,7 +253,7 @@ private function handleErrorStatus(string $sent, int $status, string $reason, st $requestId = \is_array($request) ? ($request['id'] ?? null) : null; $answer = '' === trim($body) ? null : json_decode($body, true); - if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId) { + if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId && self::isWellFormedAnswer($answer)) { $this->handleMessage($body); return; @@ -272,6 +272,23 @@ private function handleErrorStatus(string $sent, int $status, string $reason, st $this->state->storeResponse($requestId, $error->jsonSerialize()); } + /** + * Whether the parser would read $answer as a result or an error at all, + * rather than drop it and leave the request to time out. + * + * @param array $answer + */ + private static function isWellFormedAnswer(array $answer): bool + { + if (\array_key_exists('result', $answer)) { + return true; + } + + $error = $answer['error'] ?? null; + + return \is_array($error) && \is_int($error['code'] ?? null) && \is_string($error['message'] ?? null); + } + /** * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 5f3d3a6ee..6826fe649 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -157,6 +157,8 @@ public static function probeRefusalProvider(): iterable { yield 'a JSON-RPC error without an id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Bad Request: Server not initialized"}}']; yield 'a JSON-RPC error under another id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":999,"error":{"code":-32600,"message":"Bad Request"}}']; + yield 'a body under the request id that is no JSON-RPC message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"message":"Bad Request"}']; + yield 'an error under the request id without a message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32600}}']; yield 'an empty body' => [400, [], '']; yield 'a plain-text body' => [404, ['Content-Type' => 'text/plain'], 'Not Found']; } From e512dec6e19d9c4932e026a12a347f78c745466b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 8 Oct 2026 00:07:36 +0200 Subject: [PATCH 12/23] [Client] Deliver progress in order with the notifications around it --- src/Client/Transport/HttpTransport.php | 3 ++ src/Client/Transport/StdioTransport.php | 3 ++ .../Client/Transport/HttpTransportTest.php | 28 +++++++++++++++++ .../Client/Transport/StdioTransportTest.php | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 37b59911c..5ef2b651b 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -647,6 +647,9 @@ private function processSSEEvent(string $event): void if (!empty($data)) { $this->handleMessage($data); + // Delivered now rather than after the whole read, so progress + // keeps its place among the notifications sent around it. + $this->processProgress(); } } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index cb3f37622..f536aa989 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -273,6 +273,9 @@ private function processInput(): void $trimmed = trim($line); if (!empty($trimmed)) { $this->handleMessage($trimmed); + // Delivered now rather than after the whole read, so progress + // keeps its place among the notifications sent around it. + $this->processProgress(); } } diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 6826fe649..dfb77277b 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -13,14 +13,20 @@ use Mcp\Client; use Mcp\Client\CancellationTokenInterface; +use Mcp\Client\Configuration; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Notification\LoggingMessageNotification; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; @@ -257,6 +263,28 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertSame(['server/discover'], $httpClient->methods); } + #[TestDox('progress and other notifications on one stream reach the caller in the order they were sent')] + public function testProgressKeepsItsPlaceAmongNotifications(): void + { + $order = []; + $protocol = new Protocol(notificationHandlers: [new LoggingNotificationHandler(static function (LoggingMessageNotification $n) use (&$order): void { + $order[] = 'log '.$n->data; + })]); + $transport = $this->createTransport(); + $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); + (new \ReflectionProperty($transport, 'activeProgressCallback'))->setValue($transport, static function (float $progress) use (&$order): void { + $order[] = 'progress '.$progress; + }); + + $this->setActiveStream($transport, $this->factory->createStream( + 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'."\n\n" + .'data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'."\n\n", + )); + $this->invokeProcessSseStream($transport); + + $this->assertSame(['progress 1', 'log done'], $order); + } + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] public function testSseBufferIsBoundedByConfiguredCap(): void { diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index 983c31ab2..99cfab0a9 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -12,11 +12,17 @@ namespace Mcp\Tests\Unit\Client\Transport; use Mcp\Client; +use Mcp\Client\Configuration; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\StdioTransport; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Notification\LoggingMessageNotification; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -89,6 +95,30 @@ public function testClosedOutputFailsPendingRequests(): void $this->assertStringContainsString('no longer running', $response->message); } + #[TestDox('progress and other notifications read in one go reach the caller in the order they were sent')] + public function testProgressKeepsItsPlaceAmongNotifications(): void + { + $order = []; + $protocol = new Protocol(notificationHandlers: [new LoggingNotificationHandler(static function (LoggingMessageNotification $n) use (&$order): void { + $order[] = 'log '.$n->data; + })]); + $transport = new StdioTransport(command: 'true'); + $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); + (new \ReflectionProperty($transport, 'activeProgressCallback'))->setValue($transport, static function (float $progress) use (&$order): void { + $order[] = 'progress '.$progress; + }); + + $this->setStdout($transport, $this->stream( + '{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'."\n" + .'{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'."\n" + // Keeps the stream open, so the read is about ordering and not the server leaving. + .'{"partial":', + )); + $this->invokeProcessInput($transport); + + $this->assertSame(['progress 1', 'log done'], $order); + } + #[TestDox('a server that exits fails the connection at once instead of timing out')] public function testExitedServerFailsTheConnection(): void { From aba959c1d5e704665df8db23cb110315eb4b1e71 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 9 Oct 2026 22:47:22 +0200 Subject: [PATCH 13/23] [Client] Keep a final answer read together with the end of the stdio output --- src/Client/Transport/StdioTransport.php | 4 +++- .../Client/Transport/StdioTransportTest.php | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index f536aa989..3ea7c749a 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -283,7 +283,9 @@ private function processInput(): void // whatever is still pending can only time out, so fail it now. Failed // as answers rather than thrown, so each waiting fiber unwinds and // clears its request instead of leaving it to time out a later one. - if (\is_resource($this->stdout) && feof($this->stdout)) { + // Only once a read brings nothing: an answer that arrived with the end + // of the output is the waiting fiber's to take first. + if (('' === $data || false === $data) && \is_resource($this->stdout) && feof($this->stdout)) { $this->failPending('The server process closed its output; it is no longer running.'); } } diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index 99cfab0a9..e5850529a 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -22,6 +22,7 @@ use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\LoggingMessageNotification; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -119,6 +120,23 @@ public function testProgressKeepsItsPlaceAmongNotifications(): void $this->assertSame(['progress 1', 'log done'], $order); } + #[TestDox('a final answer read together with the end of the output is kept, not overwritten by the failure')] + public function testFinalAnswerBeforeClosedOutputIsKept(): void + { + $transport = new StdioTransport(command: 'true'); + $state = new ClientState(); + $transport->setState($state); + $transport->onMessage(static function (string $message) use ($state): void { + $state->storeResponse(1, json_decode($message, true, flags: \JSON_THROW_ON_ERROR)); + }); + $state->addPendingRequest(1, 120); + + $this->setStdout($transport, $this->stream('{"jsonrpc":"2.0","id":1,"result":{}}'."\n")); + $this->invokeProcessInput($transport); + + $this->assertInstanceOf(Response::class, $state->consumeResponse(1)); + } + #[TestDox('a server that exits fails the connection at once instead of timing out')] public function testExitedServerFailsTheConnection(): void { From a78826f1fd7c5c93670b807396b026c174e9454e Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 9 Oct 2026 22:48:34 +0200 Subject: [PATCH 14/23] [Client] Check an HTTP refusal body with the message parsers before trusting it --- src/Client/Transport/HttpTransport.php | 10 +++++----- tests/Unit/Client/Transport/HttpTransportTest.php | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 5ef2b651b..b7518c5bd 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -280,13 +280,13 @@ private function handleErrorStatus(string $sent, int $status, string $reason, st */ private static function isWellFormedAnswer(array $answer): bool { - if (\array_key_exists('result', $answer)) { - return true; + try { + \array_key_exists('error', $answer) ? Error::fromArray($answer) : Response::fromArray($answer); + } catch (InvalidArgumentException) { + return false; } - $error = $answer['error'] ?? null; - - return \is_array($error) && \is_int($error['code'] ?? null) && \is_string($error['message'] ?? null); + return true; } /** diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index dfb77277b..ab1620f08 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -165,6 +165,9 @@ public static function probeRefusalProvider(): iterable yield 'a JSON-RPC error under another id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":999,"error":{"code":-32600,"message":"Bad Request"}}']; yield 'a body under the request id that is no JSON-RPC message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"message":"Bad Request"}']; yield 'an error under the request id without a message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32600}}']; + yield 'a null result under the request id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":null}']; + yield 'a scalar result under the request id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":"nope"}']; + yield 'an error under the request id without the JSON-RPC version' => [400, ['Content-Type' => 'application/json'], '{"id":1,"error":{"code":-32600,"message":"Bad Request"}}']; yield 'an empty body' => [400, [], '']; yield 'a plain-text body' => [404, ['Content-Type' => 'text/plain'], 'Not Found']; } From fafc3e59d03933c54e7e20fc30727ebc0c6c5805 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:27:43 +0200 Subject: [PATCH 15/23] [Client] Never file an HTTP refusal of a client response under the server's id --- src/Client/Transport/HttpTransport.php | 6 +++- .../Client/Transport/HttpTransportTest.php | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index b7518c5bd..7c9627bc3 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -246,11 +246,15 @@ private static function isNotification(string $data): bool * typically refuses a request it did not expect — is turned into an error * for that request, so the caller learns of it now rather than at its * timeout. That is what a probe for the modern era relies on to fall back. + * + * A refused response to a server's request is only logged: its id is the + * server's, and filing an error under it would answer whichever request of + * this client happens to share it. */ private function handleErrorStatus(string $sent, int $status, string $reason, string $body): void { $request = json_decode($sent, true); - $requestId = \is_array($request) ? ($request['id'] ?? null) : null; + $requestId = \is_array($request) && \array_key_exists('method', $request) ? ($request['id'] ?? null) : null; $answer = '' === trim($body) ? null : json_decode($body, true); if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId && self::isWellFormedAnswer($answer)) { diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index ab1620f08..6f7365490 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -266,6 +266,37 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertSame(['server/discover'], $httpClient->methods); } + /** + * @return iterable + */ + public static function refusedResponseProvider(): iterable + { + yield 'an empty body' => ['']; + yield 'an error under the same id' => ['{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Bad Request"}}']; + } + + #[DataProvider('refusedResponseProvider')] + #[TestDox('a refused answer to a server request never answers a client request sharing its id: $_dataName')] + public function testRefusedResponseLeavesClientRequestsAlone(string $body): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn(new Response(404, ['Content-Type' => 'application/json'], $body)); + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + $state = new ClientState(); + $transport->setState($state); + $dispatched = []; + $transport->onMessage(static function (string $message) use (&$dispatched): void { + $dispatched[] = $message; + }); + $state->addPendingRequest(1, 120); + + $transport->send('{"jsonrpc":"2.0","id":1,"result":{}}'); + + $this->assertNull($state->consumeResponse(1)); + $this->assertSame([], $dispatched); + } + #[TestDox('progress and other notifications on one stream reach the caller in the order they were sent')] public function testProgressKeepsItsPlaceAmongNotifications(): void { From 8475897efd40d2cbfc9cd782dd47c23a7e6d6d1d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:28:58 +0200 Subject: [PATCH 16/23] [Client] Fail the probe on a lost stdio connection instead of falling back --- src/Client/Protocol.php | 5 +++++ src/Client/Transport/StdioTransport.php | 9 ++++++--- src/Client/Transport/TransportInterface.php | 7 +++++++ tests/Unit/Client/ProtocolTest.php | 18 ++++++++++++++++++ .../Client/Transport/StdioTransportTest.php | 2 ++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 8e484ac10..b6590401d 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -301,6 +301,11 @@ private function adopt(Response|Error $probe): Response|Error|null \assert(null !== $this->envelope); if ($probe instanceof Error) { + // An outage is not an answer about the era: nothing to fall back to. + if (\is_array($probe->data) && true === ($probe->data[TransportInterface::CONNECTION_LOST] ?? null)) { + return $probe; + } + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $probe->code) { return null; } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 3ea7c749a..59d15cf8d 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -286,11 +286,14 @@ private function processInput(): void // Only once a read brings nothing: an answer that arrived with the end // of the output is the waiting fiber's to take first. if (('' === $data || false === $data) && \is_resource($this->stdout) && feof($this->stdout)) { - $this->failPending('The server process closed its output; it is no longer running.'); + $this->failPending('The server process closed its output; it is no longer running.', [self::CONNECTION_LOST => true]); } } - private function failPending(string $reason): void + /** + * @param array|null $data + */ + private function failPending(string $reason, ?array $data = null): void { if (null === $this->state) { return; @@ -298,7 +301,7 @@ private function failPending(string $reason): void foreach ($this->state->getPendingRequests() as $pending) { $requestId = $pending['request_id']; - $this->state->storeResponse($requestId, Error::forInternalError($reason, $requestId)->jsonSerialize()); + $this->state->storeResponse($requestId, (new Error($requestId, Error::INTERNAL_ERROR, $reason, $data))->jsonSerialize()); } } diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index b554c8f40..8df6f260d 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -30,6 +30,13 @@ */ interface TransportInterface { + /** + * Key a transport sets to true in the data of the errors it files for + * pending requests once the connection itself is gone, so they are not + * mistaken for the server's answer. + */ + public const CONNECTION_LOST = 'connectionLost'; + /** * Connect to the MCP server and perform initialization handshake. * diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 1d984626f..5bc9ffd93 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -134,6 +134,24 @@ public function testSilentProbeFallsBackToTheHandshake(): void $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); } + #[TestDox('a connection lost during the probe fails the attempt instead of falling back')] + public function testLostConnectionDuringProbeDoesNotFallBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, ignoreDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $fiber = new \Fiber(static fn () => $protocol->initialize($config)); + $suspended = $fiber->start(); + $fiber->resume(new Error($suspended['request_id'], Error::INTERNAL_ERROR, 'The server process closed its output; it is no longer running.', [TransportInterface::CONNECTION_LOST => true])); + + $this->assertTrue($fiber->isTerminated()); + $error = $fiber->getReturn(); + $this->assertInstanceOf(Error::class, $error); + $this->assertStringContainsString('no longer running', $error->message); + $this->assertSame(['server/discover'], $transport->methods); + } + #[TestDox('a refusal naming only handshake revisions falls back rather than failing')] public function testRefusalNamingHandshakeRevisionsFallsBack(): void { diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index e5850529a..951d831f3 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -17,6 +17,7 @@ use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\StdioTransport; +use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\ClientCapabilities; @@ -94,6 +95,7 @@ public function testClosedOutputFailsPendingRequests(): void $this->assertInstanceOf(Error::class, $response); $this->assertStringContainsString('no longer running', $response->message); + $this->assertSame([TransportInterface::CONNECTION_LOST => true], $response->data); } #[TestDox('progress and other notifications read in one go reach the caller in the order they were sent')] From 9d3e4632f591ffc39aaa08ab9f0e04922424fea5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:29:28 +0200 Subject: [PATCH 17/23] [Client] Read the revisions a refusal names in one place --- src/Client/Protocol.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index b6590401d..5c02952ca 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -322,7 +322,7 @@ private function adopt(Response|Error $probe): Response|Error|null } } - $named = \is_array($probe->data['supported'] ?? null) ? array_filter($probe->data['supported'], is_string(...)) : []; + $named = self::namedVersions($probe); return Error::forInvalidRequest(\sprintf('Server supports none of the protocol versions this client speaks (it advertises %s).', [] === $named ? 'none' : implode(', ', $named)), $probe->id); } @@ -404,7 +404,7 @@ private function handshake(ProtocolVersion $offered, Configuration $config): Res $response = $this->request($request, $config->initTimeout); if ($response instanceof Error && Error::UNSUPPORTED_PROTOCOL_VERSION === $response->code) { - $named = \is_array($response->data['supported'] ?? null) ? array_filter($response->data['supported'], is_string(...)) : []; + $named = self::namedVersions($response); return new Error($response->id, $response->code, \sprintf( 'Server does not speak protocol version %s; it supports %s.', @@ -509,13 +509,19 @@ private static function mutualModern(Error $error): ?ProtocolVersion */ private static function supportedVersions(Error $error): array { - $data = \is_array($error->data) ? $error->data : []; - $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; + return array_values(array_filter(array_map(ProtocolVersion::tryFrom(...), self::namedVersions($error)))); + } + + /** + * The revisions a `-32022` refusal names, known to this SDK or not. + * + * @return list + */ + private static function namedVersions(Error $error): array + { + $supported = \is_array($error->data) && \is_array($error->data['supported'] ?? null) ? $error->data['supported'] : []; - return array_values(array_filter(array_map( - static fn (mixed $v): ?ProtocolVersion => \is_string($v) ? ProtocolVersion::tryFrom($v) : null, - $supported, - ))); + return array_values(array_filter($supported, is_string(...))); } /** From 0dce8a9e3a402955bd1af84c651e72485d0383c6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:29:55 +0200 Subject: [PATCH 18/23] [Client] Forget the modern log level on reconnect --- src/Client/Protocol.php | 4 ++++ tests/Unit/Client/ProtocolTest.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 5c02952ca..7909e1330 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -142,6 +142,10 @@ public function connect(TransportInterface $transport, Configuration $config): v // or another — has said nothing yet. $this->tools = new ToolCatalog($this->logger); + // Like `logging/setLevel` on the handshake era, a level asked for on + // one connection is not carried over to the next. + $this->logLevel = null; + $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 5bc9ffd93..6a2285b77 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -21,6 +21,7 @@ use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -350,6 +351,25 @@ public function testReconnectResetsToolCatalog(): void $this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect'); } + #[TestDox('reconnecting forgets the log level asked for on the previous connection')] + public function testReconnectResetsLogLevel(): void + { + $protocol = new Protocol(); + $config = $this->createConfiguration(ProtocolVersion::V2026_07_28); + + $protocol->connect(new RecordingTransport(ProtocolVersion::V2026_07_28->value), $config); + $protocol->initialize($config); + $protocol->setLogLevel(LoggingLevel::Debug); + + $protocol->connect($transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value), $config); + $protocol->initialize($config); + + $this->assertNotSame([], $transport->metas); + foreach ($transport->metas as $meta) { + $this->assertArrayNotHasKey(RequestMeta::LOG_LEVEL, $meta); + } + } + #[TestDox('an empty inputResponses map is retried as a JSON object, never an array')] public function testEmptyInputResponsesEncodesAsJsonObject(): void { From 75fd305e9aca0d1d1d94699b7f17695b354a5890 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:30:35 +0200 Subject: [PATCH 19/23] [Client] Pick the newest advertised revision without relying on declaration order --- src/Client/Protocol.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 7909e1330..260196008 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -264,6 +264,8 @@ private function negotiate(Configuration $config): Response|Error return $handshake; } + // Unreachable: the second attempt always returns. Kept so the method + // cannot fall off its end should the loop change. return Error::forInternalError('Protocol negotiation did not settle on a revision.'); } @@ -343,7 +345,7 @@ private function adopt(Response|Error $probe): Response|Error|null $chosen = null; foreach (ProtocolVersion::modernVersions() as $version) { - if (\in_array($version->value, $advertised, true)) { + if (\in_array($version->value, $advertised, true) && (null === $chosen || $version->isAtLeast($chosen))) { $chosen = $version; } } From 2850a99f5b3536e745b92ccdae4e05ea12037b08 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:36:43 +0200 Subject: [PATCH 20/23] [Client] Trim comments --- src/Client.php | 13 +---- src/Client/Builder.php | 13 +---- src/Client/Configuration.php | 7 +-- src/Client/Protocol.php | 57 +++------------------ src/Client/Stateless/RequestEnvelope.php | 8 --- src/Client/Transport/HttpTransport.php | 19 +------ src/Client/Transport/StdioTransport.php | 12 +---- src/Client/Transport/TransportInterface.php | 4 +- tests/Integration/ElicitationTest.php | 4 +- tests/Integration/Fixture/http.php | 10 +--- tests/Integration/HandshakeTest.php | 3 -- tests/Integration/HttpNegotiationTest.php | 10 +--- tests/Integration/IntegrationTestCase.php | 3 -- tests/Integration/NotificationTest.php | 2 - tests/Unit/Client/ProtocolTest.php | 5 +- 15 files changed, 21 insertions(+), 149 deletions(-) diff --git a/src/Client.php b/src/Client.php index 22e862823..cb88a3b26 100644 --- a/src/Client.php +++ b/src/Client.php @@ -174,10 +174,7 @@ public function getProtocolVersion(): ?ProtocolVersion } /** - * Check that the server is reachable and answering. - * - * A `ping` on the handshake era. The modern era removed it, so there the - * check is a `server/discover`, which every modern server answers. + * Check that the server is reachable: `ping`, or `server/discover` on the modern era, which removed it. */ public function ping(): void { @@ -340,11 +337,7 @@ public function complete(PromptReference|ResourceReference $ref, array $argument } /** - * Set the minimum logging level for server log messages. - * - * On the handshake era this is a `logging/setLevel` request. The modern - * era removed it: there the level rides on every request that follows, - * and until one is set the server sends no log messages at all. + * Set the minimum logging level for server log messages; on the modern era it rides on every following request. */ public function setLoggingLevel(LoggingLevel $level): void { @@ -381,8 +374,6 @@ public function sendRootsListChanged(): void throw new ConnectionException('Client is not connected. Call connect() first.'); } - // The modern era removed roots, so a server on it has nothing to - // refresh — and nothing to tell. if ($this->protocol->isModern()) { $this->logger->debug('Not sending "notifications/roots/list_changed": the connection is on the modern era, which removed roots.'); diff --git a/src/Client/Builder.php b/src/Client/Builder.php index af6cedf8e..aefd1a47a 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -67,12 +67,7 @@ public function setClientInfo(string $name, string $version, ?string $descriptio } /** - * Set the protocol version the client prefers. - * - * Defaults to 2025-11-25. A modern revision is probed for with - * `server/discover` and falls back to the `initialize` handshake when the - * server turns out not to speak it, see {@see self::setFallbackProtocolVersion()}; - * a handshake revision skips the probe and opens with the handshake. + * Set the protocol version the client prefers, defaults to 2025-11-25; a modern one is probed for first. */ public function setProtocolVersion(ProtocolVersion $protocolVersion): self { @@ -82,11 +77,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self } /** - * Set the handshake revision a modern client falls back to when the server - * does not speak the modern era. Defaults to 2025-11-25. - * - * Null makes the client modern-only: a server without the modern era then - * fails the connection instead. + * Set the handshake revision a modern client falls back to, defaults to 2025-11-25; null makes it modern-only. */ public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self { diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index b01d21767..ea79542b1 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -24,12 +24,7 @@ class Configuration { /** - * @param ProtocolVersion $protocolVersion the revision the client prefers. A modern one is - * probed for with `server/discover` before anything else - * @param ProtocolVersion|null $fallbackProtocolVersion the handshake revision offered through `initialize` when - * a probe shows the server does not speak the modern era; - * null makes a modern client modern-only. Unused when - * $protocolVersion is a handshake revision already + * @param ProtocolVersion|null $fallbackProtocolVersion handshake revision a modern client falls back to; null makes it modern-only */ public function __construct( public readonly Implementation $clientInfo, diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 260196008..dbc3b68c8 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -80,7 +80,6 @@ class Protocol private ToolCatalog $tools; - /** What a modern-era connection stamps on every request, see {@see self::setLogLevel()}. */ private ?LoggingLevel $logLevel = null; private readonly InputRequestResolver $inputRequests; @@ -142,8 +141,6 @@ public function connect(TransportInterface $transport, Configuration $config): v // or another — has said nothing yet. $this->tools = new ToolCatalog($this->logger); - // Like `logging/setLevel` on the handshake era, a level asked for on - // one connection is not carried over to the next. $this->logLevel = null; $transport->setState($this->state); @@ -179,19 +176,12 @@ private function headersFor(string $payload): array /** * Ready the connection for use, settling which protocol era it speaks. * - * A handshake revision opens with `initialize`, as every revision up to - * 2025-11-25 does. A modern one has no handshake: the client probes with - * `server/discover` instead, and falls back to the handshake when the - * answer shows the server does not speak the modern era — see - * {@see self::negotiate()}. - * * @param Configuration $config The client configuration * * @return Response>|Error */ public function initialize(Configuration $config): Response|Error { - // Settled anew on every attempt: a reconnect may reach another server. $this->envelope = null; $this->headers = null; @@ -203,16 +193,7 @@ public function initialize(Configuration $config): Response|Error } /** - * Probe for the modern era, falling back to the handshake when the server - * does not speak it. - * - * Only positive evidence keeps the connection modern: a `DiscoverResult` - * naming a modern revision this client speaks, or a refusal naming one - * (which {@see self::request()} has already retried with). Any other error, - * silence until the timeout, or a server advertising nothing but handshake - * revisions identifies a server from before the modern era. The fallback is - * deliberately not keyed to any one error code: such servers answer an - * unknown request before `initialize` however they like, or not at all. + * Probe for the modern era, falling back to the handshake unless the server proves to speak it. * * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility @@ -223,9 +204,7 @@ private function negotiate(Configuration $config): Response|Error { $version = $config->protocolVersion; - // Twice at most: a probe that timed out here may still have reached a - // slow-starting server and settled it on the modern era, which the - // fallback handshake then hears about as a refusal naming that era. + // Twice: a timed-out probe may still have settled a slow server on the modern era. for ($attempt = 0; $attempt < 2; ++$attempt) { $this->enterModernEra($version, $config); @@ -264,8 +243,6 @@ private function negotiate(Configuration $config): Response|Error return $handshake; } - // Unreachable: the second attempt always returns. Kept so the method - // cannot fall off its end should the loop change. return Error::forInternalError('Protocol negotiation did not settle on a revision.'); } @@ -275,18 +252,13 @@ private function enterModernEra(ProtocolVersion $version, Configuration $config) $this->headers = new HeaderFactory($this->tools); } - /** - * Whether the connection settled on the modern era, where every request - * carries its own metadata. - */ public function isModern(): bool { return null !== $this->envelope; } /** - * Ask for the server's log messages from $level up on every request that - * follows — the modern era's stand-in for `logging/setLevel`. + * The modern era's stand-in for `logging/setLevel`. */ public function setLogLevel(LoggingLevel $level): void { @@ -295,8 +267,7 @@ public function setLogLevel(LoggingLevel $level): void } /** - * Reads a probe's answer: the connection, now modern; an error ending the - * attempt; or null when the server is not a modern one. + * The modern connection, an error ending the attempt, or null to fall back. * * @param Response>|Error $probe * @@ -307,7 +278,6 @@ private function adopt(Response|Error $probe): Response|Error|null \assert(null !== $this->envelope); if ($probe instanceof Error) { - // An outage is not an answer about the era: nothing to fall back to. if (\is_array($probe->data) && true === ($probe->data[TransportInterface::CONNECTION_LOST] ?? null)) { return $probe; } @@ -316,10 +286,7 @@ private function adopt(Response|Error $probe): Response|Error|null return null; } - // A refusal naming a modern revision was already retried with it, - // so reaching here means it named none this client speaks. A server - // naming handshake revisions is still reachable through them; one - // naming neither is a modern server this client cannot talk to. + // Modern revisions it names were already retried by request(). $supported = self::supportedVersions($probe); foreach ($supported as $version) { @@ -335,8 +302,6 @@ private function adopt(Response|Error $probe): Response|Error|null $advertised = $probe->result['supportedVersions'] ?? null; - // Not a DiscoverResult, which has to name its revisions, so not evidence - // of the modern era either. if (!\is_array($advertised)) { return null; } @@ -355,8 +320,6 @@ private function adopt(Response|Error $probe): Response|Error|null } if (null === $chosen) { - // It speaks discover but advertises only handshake revisions: a - // statement of where it can be reached, not an incompatibility. return null; } @@ -394,9 +357,6 @@ private function settleModern(Response $probe): Response } /** - * The `initialize` handshake: offer a revision, take the server's answer, - * confirm with `notifications/initialized`. - * * @return Response>|Error */ private function handshake(ProtocolVersion $offered, Configuration $config): Response|Error @@ -458,8 +418,7 @@ private function handshake(ProtocolVersion $offered, Configuration $config): Res } /** - * Read defensively: none of a `DiscoverResult` beyond its revisions is - * load-bearing for the requests that follow. + * Read defensively: nothing beyond the revisions is load-bearing. * * @param array $result */ @@ -509,8 +468,6 @@ private static function mutualModern(Error $error): ?ProtocolVersion } /** - * The revisions a `-32022` refusal names, as far as this SDK knows them. - * * @return list */ private static function supportedVersions(Error $error): array @@ -519,8 +476,6 @@ private static function supportedVersions(Error $error): array } /** - * The revisions a `-32022` refusal names, known to this SDK or not. - * * @return list */ private static function namedVersions(Error $error): array diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php index f2ebce2ac..ea42f5c9c 100644 --- a/src/Client/Stateless/RequestEnvelope.php +++ b/src/Client/Stateless/RequestEnvelope.php @@ -32,10 +32,6 @@ */ final class RequestEnvelope { - /** - * @param LoggingLevel|null $logLevel the least severe log message the client wants to hear about while a - * request runs; null asks for none, which is what this revision assumes - */ public function __construct( private readonly ProtocolVersion $protocolVersion, private readonly ClientCapabilities $capabilities, @@ -54,10 +50,6 @@ public function withProtocolVersion(ProtocolVersion $protocolVersion): self return new self($protocolVersion, $this->capabilities, $this->clientInfo, $this->logLevel); } - /** - * The per-request replacement for `logging/setLevel`, which this revision - * removed: the level now rides on every request instead of being set once. - */ public function withLogLevel(?LoggingLevel $logLevel): self { return new self($this->protocolVersion, $this->capabilities, $this->clientInfo, $logLevel); diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 7c9627bc3..93e267f47 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -238,18 +238,7 @@ private static function isNotification(string $data): bool } /** - * Answers a request the server refused at the HTTP level. - * - * A well-formed JSON-RPC answer carrying the request's id is handled like - * any other. Anything else — an empty, non-JSON or malformed body, or an - * error without that id, which is how a server from before the modern era - * typically refuses a request it did not expect — is turned into an error - * for that request, so the caller learns of it now rather than at its - * timeout. That is what a probe for the modern era relies on to fall back. - * - * A refused response to a server's request is only logged: its id is the - * server's, and filing an error under it would answer whichever request of - * this client happens to share it. + * Fails a request refused at the HTTP level at once, rather than at its timeout. */ private function handleErrorStatus(string $sent, int $status, string $reason, string $body): void { @@ -277,9 +266,6 @@ private function handleErrorStatus(string $sent, int $status, string $reason, st } /** - * Whether the parser would read $answer as a result or an error at all, - * rather than drop it and leave the request to time out. - * * @param array $answer */ private static function isWellFormedAnswer(array $answer): bool @@ -651,8 +637,7 @@ private function processSSEEvent(string $event): void if (!empty($data)) { $this->handleMessage($data); - // Delivered now rather than after the whole read, so progress - // keeps its place among the notifications sent around it. + // Now, so progress keeps its order among notifications. $this->processProgress(); } } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 59d15cf8d..de81cc050 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -120,8 +120,6 @@ public function send(string $data): void throw new ConnectionException('Process stdin not available'); } - // Silenced: a server that has exited is reported as the connection - // failure it is, not as a broken-pipe warning. if (false === @fwrite($this->stdin, $data."\n")) { throw new ConnectionException('Could not write to the server process; it is no longer running.'); } @@ -273,18 +271,12 @@ private function processInput(): void $trimmed = trim($line); if (!empty($trimmed)) { $this->handleMessage($trimmed); - // Delivered now rather than after the whole read, so progress - // keeps its place among the notifications sent around it. + // Now, so progress keeps its order among notifications. $this->processProgress(); } } - // Everything it wrote has been read, and it will write nothing more: - // whatever is still pending can only time out, so fail it now. Failed - // as answers rather than thrown, so each waiting fiber unwinds and - // clears its request instead of leaving it to time out a later one. - // Only once a read brings nothing: an answer that arrived with the end - // of the output is the waiting fiber's to take first. + // Only on an empty read, so an answer arriving with the end of output is taken first. if (('' === $data || false === $data) && \is_resource($this->stdout) && feof($this->stdout)) { $this->failPending('The server process closed its output; it is no longer running.', [self::CONNECTION_LOST => true]); } diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index 8df6f260d..4ce73c4cd 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -31,9 +31,7 @@ interface TransportInterface { /** - * Key a transport sets to true in the data of the errors it files for - * pending requests once the connection itself is gone, so they are not - * mistaken for the server's answer. + * Error data key marking an error the transport filed because the connection is gone. */ public const CONNECTION_LOST = 'connectionLost'; diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php index 66753cb09..b421ff5ee 100644 --- a/tests/Integration/ElicitationTest.php +++ b/tests/Integration/ElicitationTest.php @@ -77,9 +77,7 @@ public function testCapabilityIsVisibleToTheServer(): void public function testAdvertisedCapabilityWithoutHandler(): void { // The client answers "method not found", which the gateway raises inside - // the tool as a ClientException rather than leaving it waiting. That is - // a handshake-era exchange: the modern era has no way to answer an ask - // with an error, see below. + // the tool as a ClientException rather than leaving it waiting. $client = $this->connect( 'elicitation', $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), diff --git a/tests/Integration/Fixture/http.php b/tests/Integration/Fixture/http.php index 6b9d31740..fd0cbcdab 100644 --- a/tests/Integration/Fixture/http.php +++ b/tests/Integration/Fixture/http.php @@ -9,12 +9,7 @@ * file that was distributed with this source code. */ -/* - * Server for {@see \Mcp\Tests\Integration\HttpNegotiationTest}, run under `php -S`. - * - * MCP_INTEGRATION_HANDSHAKE_ONLY makes it a server from before the modern era, - * MCP_INTEGRATION_MODERN_ONLY one that serves nothing else. - */ +// Server for HttpNegotiationTest under `php -S`, era picked by MCP_INTEGRATION_HANDSHAKE_ONLY / MCP_INTEGRATION_MODERN_ONLY. use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; @@ -28,8 +23,7 @@ $builder = Server::builder() ->setServerInfo('integration-server', '1.0.0') ->setInstructions('Be brief.') - // Nothing survives between requests under `php -S`, so the handshake era - // keeps its sessions on disk. + // `php -S` keeps nothing between requests. ->setSession(new FileSessionStore((string) getenv('MCP_INTEGRATION_SESSIONS'))) ->addTool(static fn (string $text): string => $text, name: 'echo', description: 'Echoes the text back.'); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 1cd9f1e15..c2539cabf 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -66,8 +66,6 @@ public static function provideNegotiations(): iterable // 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]; - // A server from before the modern era refuses the probe, and the - // client takes the handshake instead. yield 'server without the modern era' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_11_25, true]; yield 'server without the modern era, client falling back further' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_06_18, true, ProtocolVersion::V2025_06_18]; yield 'server without the modern era pinning a revision' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26, true]; @@ -80,7 +78,6 @@ public function testFallbackDoesNotWaitOutTheProbe(): void $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28), self::environment(null, true)); $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); - // Well under the five seconds the probe would otherwise be waited for. $this->assertLessThan(3, microtime(true) - $started); } diff --git a/tests/Integration/HttpNegotiationTest.php b/tests/Integration/HttpNegotiationTest.php index bb37e8a65..b4dff9a10 100644 --- a/tests/Integration/HttpNegotiationTest.php +++ b/tests/Integration/HttpNegotiationTest.php @@ -23,14 +23,7 @@ use Symfony\Component\Process\Process; /** - * What a client and a server settle on over Streamable HTTP, for every pairing - * of what each end speaks. - * - * {@see HandshakeTest} covers the same ground over stdio, where the era is - * settled once per process; here it is a property of the endpoint, and a - * server from before the modern era answers the probe with an HTTP refusal. - * - * @see Fixture/http.php for the server under test + * Era negotiation over Streamable HTTP, {@see HandshakeTest} for stdio. */ final class HttpNegotiationTest extends TestCase { @@ -89,7 +82,6 @@ public function testNegotiatesAndTalks(string $server, ?ProtocolVersion $clientV $started = microtime(true); $client->connect($this->transport()); - // A refused probe is answered at once; only silence would cost the timeout. $this->assertLessThan(self::TIMEOUT - 1, microtime(true) - $started); $this->assertSame($expected, $client->getProtocolVersion()); $this->assertSame('integration-server', $client->getServerInfo()?->name); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index e057d4b63..05d05f359 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -47,9 +47,6 @@ protected function clientBuilder(): ClientBuilder } /** - * Both eras one fixture server serves, for behaviour a caller should see - * the same on either. - * * @return iterable */ public static function provideEras(): iterable diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php index 76d4b2fbd..6da507422 100644 --- a/tests/Integration/NotificationTest.php +++ b/tests/Integration/NotificationTest.php @@ -76,8 +76,6 @@ static function (LoggingMessageNotification $notification) use (&$logged): void $this->assertSame($era, $client->getProtocolVersion()); - // A request on the handshake era, a level carried by every request - // that follows on the modern one — the caller cannot tell. $client->setLoggingLevel(LoggingLevel::Info); $client->callTool('work'); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 6a2285b77..ece4b405b 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -122,8 +122,7 @@ public function testSilentProbeFallsBackToTheHandshake(): void $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); - // The transport times a request out by resuming its fiber with an error; - // driven by hand here, the way StdioTransport::tick() would. + // Times the probe out by hand, like StdioTransport::tick() would. $fiber = new \Fiber(static fn () => $protocol->initialize($config)); $suspended = $fiber->start(); @@ -233,8 +232,6 @@ public function testDiscoveryWithoutRevisionsIsNotModernEvidence(?ProtocolVersio #[TestDox('a handshake refused because the server already settled on the modern era probes again')] public function testLateModernSettlementIsProbedAgain(): void { - // The first probe went unanswered in time; by the handshake, the server - // had answered it and settled on the modern era. $transport = new RecordingTransport( ProtocolVersion::V2025_11_25->value, refuseDiscovery: true, From 7cc324e873b9b5a3b5daf14ef62f50d8bce0d038 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:45:27 +0200 Subject: [PATCH 21/23] [Client] Re-probe once by recursion instead of a two-pass loop --- src/Client/Protocol.php | 59 ++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index dbc3b68c8..04558b29b 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -200,50 +200,43 @@ public function initialize(Configuration $config): Response|Error * * @return Response>|Error */ - private function negotiate(Configuration $config): Response|Error + private function negotiate(Configuration $config, ?ProtocolVersion $version = null, bool $reprobed = false): Response|Error { - $version = $config->protocolVersion; + $this->enterModernEra($version ?? $config->protocolVersion, $config); - // Twice: a timed-out probe may still have settled a slow server on the modern era. - for ($attempt = 0; $attempt < 2; ++$attempt) { - $this->enterModernEra($version, $config); + $probe = $this->request(new DiscoverRequest(), $config->initTimeout); + $adopted = $this->adopt($probe); - $probe = $this->request(new DiscoverRequest(), $config->initTimeout); - $adopted = $this->adopt($probe); - - if ($adopted instanceof Response || $adopted instanceof Error) { - return $adopted; - } - - if (null === $config->fallbackProtocolVersion) { - return Error::forInvalidRequest(\sprintf( - 'Server does not speak protocol version %s and this client is configured without a handshake fallback: %s', - $config->protocolVersion->value, - self::describe($probe), - )); - } + if ($adopted instanceof Response || $adopted instanceof Error) { + return $adopted; + } - $this->logger->info('Server does not speak the modern era; falling back to the "initialize" handshake.', [ - 'probe' => self::describe($probe), - 'offering' => $config->fallbackProtocolVersion->value, - ]); + if (null === $config->fallbackProtocolVersion) { + return Error::forInvalidRequest(\sprintf( + 'Server does not speak protocol version %s and this client is configured without a handshake fallback: %s', + $config->protocolVersion->value, + self::describe($probe), + )); + } - $this->envelope = null; - $this->headers = null; + $this->logger->info('Server does not speak the modern era; falling back to the "initialize" handshake.', [ + 'probe' => self::describe($probe), + 'offering' => $config->fallbackProtocolVersion->value, + ]); - $handshake = $this->handshake($config->fallbackProtocolVersion, $config); + $this->envelope = null; + $this->headers = null; - if ($handshake instanceof Error && 0 === $attempt && null !== $modern = self::mutualModern($handshake)) { - $this->logger->info('Server settled on the modern era after all; probing again.', ['version' => $modern->value]); - $version = $modern; + $handshake = $this->handshake($config->fallbackProtocolVersion, $config); - continue; - } + // Once more: a timed-out probe may still have settled a slow server on the modern era. + if ($handshake instanceof Error && !$reprobed && null !== $modern = self::mutualModern($handshake)) { + $this->logger->info('Server settled on the modern era after all; probing again.', ['version' => $modern->value]); - return $handshake; + return $this->negotiate($config, $modern, true); } - return Error::forInternalError('Protocol negotiation did not settle on a revision.'); + return $handshake; } private function enterModernEra(ProtocolVersion $version, Configuration $config): void From f300d7938ca01cf4c21a7c37284544fe791aeaf2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 01:51:51 +0200 Subject: [PATCH 22/23] [Client] Deliver progress as it is parsed, keeping its order within a batch --- src/Client.php | 8 ++++- .../ProgressNotificationHandler.php | 15 ++++----- src/Client/Protocol.php | 28 +++++++++++++++- src/Client/Transport/HttpTransport.php | 32 ------------------- src/Client/Transport/StdioTransport.php | 32 ------------------- src/Client/Transport/TransportInterface.php | 5 +-- .../Client/Transport/HttpTransportTest.php | 2 +- .../Client/Transport/StdioTransportTest.php | 28 ++++++++++------ 8 files changed, 61 insertions(+), 89 deletions(-) diff --git a/src/Client.php b/src/Client.php index cb88a3b26..02ec67e4a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -401,7 +401,13 @@ private function sendRequest(Request $request, ?callable $onProgress = null, ?Ca $withProgress = null !== $onProgress; $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress, $cancellation, $timeoutSeconds)); - $response = $transport->runRequest($fiber, $onProgress); + $this->protocol->setProgressCallback($onProgress); + + try { + $response = $transport->runRequest($fiber); + } finally { + $this->protocol->setProgressCallback(null); + } if ($response instanceof Error) { throw RequestException::fromError($response); diff --git a/src/Client/Handler/Notification/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php index 3c489bf0d..de3c93cf5 100644 --- a/src/Client/Handler/Notification/ProgressNotificationHandler.php +++ b/src/Client/Handler/Notification/ProgressNotificationHandler.php @@ -11,14 +11,13 @@ namespace Mcp\Client\Handler\Notification; -use Mcp\Client\State\ClientStateInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\ProgressNotification; /** * Internal handler for progress notifications. * - * Writes progress data to state for transport to consume and execute callbacks. + * Hands progress on as soon as it is parsed, so it keeps its order among other notifications. * * @author Kyrian Obikwelu * @@ -26,8 +25,11 @@ */ class ProgressNotificationHandler implements NotificationHandlerInterface { + /** + * @param \Closure(float, ?float, ?string): void $deliver + */ public function __construct( - private readonly ClientStateInterface $state, + private readonly \Closure $deliver, ) { } @@ -42,11 +44,6 @@ public function handle(Notification $notification): void return; } - $this->state->storeProgress( - (string) $notification->progressToken, - $notification->progress, - $notification->total, - $notification->message, - ); + ($this->deliver)($notification->progress, $notification->total, $notification->message); } } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 04558b29b..efdf2986c 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -82,6 +82,9 @@ class Protocol private ?LoggingLevel $logLevel = null; + /** @var (callable(float, ?float, ?string): void)|null */ + private $onProgress; + private readonly InputRequestResolver $inputRequests; /** @@ -105,7 +108,7 @@ public function __construct( $this->logger = $logger ?? new NullLogger(); $this->notificationHandlers = [ - new ProgressNotificationHandler($this->state), + new ProgressNotificationHandler($this->deliverProgress(...)), ...$notificationHandlers, ]; @@ -245,6 +248,29 @@ private function enterModernEra(ProtocolVersion $version, Configuration $config) $this->headers = new HeaderFactory($this->tools); } + /** + * Set the callback for progress on the request in flight, null to clear it. + * + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function setProgressCallback(?callable $onProgress): void + { + $this->onProgress = $onProgress; + } + + private function deliverProgress(float $progress, ?float $total, ?string $message): void + { + if (null === $this->onProgress) { + return; + } + + try { + ($this->onProgress)($progress, $total, $message); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + public function isModern(): bool { return null !== $this->envelope; diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 93e267f47..a6d777d39 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -52,9 +52,6 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa /** @var FiberSuspend|null */ private ?array $activeSuspend = null; - /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback; - /** @var StreamInterface|null Active SSE stream being read */ private ?StreamInterface $activeStream = null; @@ -286,7 +283,6 @@ private static function isWellFormedAnswer(array $answer): bool public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error { $this->activeFiber = $fiber; - $this->activeProgressCallback = $onProgress; try { $this->activeSuspend = $fiber->start(); while (!$fiber->isTerminated()) { @@ -297,7 +293,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons } finally { $this->activeFiber = null; $this->activeSuspend = null; - $this->activeProgressCallback = null; $this->activeStream?->close(); $this->activeStream = null; $this->sseBuffer = ''; @@ -456,7 +451,6 @@ private function tick(): void $this->checkInterruption(); $this->processSSEStream(); $this->processListenStream(); - $this->processProgress(); $this->checkInterruption(); $this->processFiber(); @@ -637,32 +631,6 @@ private function processSSEEvent(string $event): void if (!empty($data)) { $this->handleMessage($data); - // Now, so progress keeps its order among notifications. - $this->processProgress(); - } - } - - /** - * Process pending progress updates from session and execute callback. - */ - private function processProgress(): void - { - if (null === $this->activeProgressCallback || null === $this->state) { - return; - } - - $updates = $this->state->consumeProgressUpdates(); - - foreach ($updates as $update) { - try { - ($this->activeProgressCallback)( - $update['progress'], - $update['total'], - $update['message'], - ); - } catch (\Throwable $e) { - $this->logger->warning('Progress callback failed', ['exception' => $e]); - } } } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index de81cc050..2108e64e1 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -60,9 +60,6 @@ class StdioTransport extends BaseTransport /** @var FiberSuspend|null */ private ?array $activeSuspend = null; - /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback; - /** * @param string $command The command to run * @param array $args Command arguments @@ -136,7 +133,6 @@ public function send(string $data): void public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error { $this->activeFiber = $fiber; - $this->activeProgressCallback = $onProgress; try { $this->activeSuspend = $fiber->start(); @@ -147,7 +143,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons return $fiber->getReturn(); } finally { $this->activeFiber = null; - $this->activeProgressCallback = null; $this->activeSuspend = null; } } @@ -216,37 +211,12 @@ private function spawnProcess(): void private function tick(): void { $this->processInput(); - $this->processProgress(); $this->processFiber(); $this->processStderr(); usleep(1000); // 1ms } - /** - * Process pending progress updates from session and execute callback. - */ - private function processProgress(): void - { - if (null === $this->activeProgressCallback || null === $this->state) { - return; - } - - $updates = $this->state->consumeProgressUpdates(); - - foreach ($updates as $update) { - try { - ($this->activeProgressCallback)( - $update['progress'], - $update['total'], - $update['message'], - ); - } catch (\Throwable $e) { - $this->logger->warning('Progress callback failed', ['exception' => $e]); - } - } - } - private function processInput(): void { if (null === $this->stdout || !\is_resource($this->stdout)) { @@ -271,8 +241,6 @@ private function processInput(): void $trimmed = trim($line); if (!empty($trimmed)) { $this->handleMessage($trimmed); - // Now, so progress keeps its order among notifications. - $this->processProgress(); } } diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index 4ce73c4cd..5ca65fcd7 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -59,12 +59,9 @@ public function send(string $data): void; * The transport starts the fiber, runs its internal loop, and resumes * the fiber when a response arrives or timeout occurs. * - * During the loop, the transport checks session for progress data and - * executes the callback if provided. - * * @param McpFiber $fiber The fiber to execute * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress - * Optional callback for progress updates + * Unused: the protocol delivers progress as it is parsed * * @return Response>|Error The response or error */ diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 6f7365490..207923b89 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -306,7 +306,7 @@ public function testProgressKeepsItsPlaceAmongNotifications(): void })]); $transport = $this->createTransport(); $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); - (new \ReflectionProperty($transport, 'activeProgressCallback'))->setValue($transport, static function (float $progress) use (&$order): void { + $protocol->setProgressCallback(static function (float $progress) use (&$order): void { $order[] = 'progress '.$progress; }); diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index 951d831f3..5b85a05ee 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -25,6 +25,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\LoggingMessageNotification; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -98,8 +99,21 @@ public function testClosedOutputFailsPendingRequests(): void $this->assertSame([TransportInterface::CONNECTION_LOST => true], $response->data); } - #[TestDox('progress and other notifications read in one go reach the caller in the order they were sent')] - public function testProgressKeepsItsPlaceAmongNotifications(): void + /** + * @return iterable + */ + public static function progressAmongNotificationsProvider(): iterable + { + $progress = '{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'; + $log = '{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'; + + yield 'one per line' => [$progress."\n".$log."\n"]; + yield 'in one batch' => ['['.$progress.','.$log.']'."\n"]; + } + + #[DataProvider('progressAmongNotificationsProvider')] + #[TestDox('progress and other notifications read in one go reach the caller in the order they were sent: $_dataName')] + public function testProgressKeepsItsPlaceAmongNotifications(string $lines): void { $order = []; $protocol = new Protocol(notificationHandlers: [new LoggingNotificationHandler(static function (LoggingMessageNotification $n) use (&$order): void { @@ -107,16 +121,12 @@ public function testProgressKeepsItsPlaceAmongNotifications(): void })]); $transport = new StdioTransport(command: 'true'); $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); - (new \ReflectionProperty($transport, 'activeProgressCallback'))->setValue($transport, static function (float $progress) use (&$order): void { + $protocol->setProgressCallback(static function (float $progress) use (&$order): void { $order[] = 'progress '.$progress; }); - $this->setStdout($transport, $this->stream( - '{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'."\n" - .'{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'."\n" - // Keeps the stream open, so the read is about ordering and not the server leaving. - .'{"partial":', - )); + // The partial line keeps the stream open, so the read is about ordering and not the server leaving. + $this->setStdout($transport, $this->stream($lines.'{"partial":')); $this->invokeProcessInput($transport); $this->assertSame(['progress 1', 'log done'], $order); From 0a6f7b5959c00ca1b883e8b0ce5519bc0bb56411 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 02:03:08 +0200 Subject: [PATCH 23/23] [Tests] Pin the server's refusal code an id-less HTTP refusal carries --- tests/Integration/HttpNegotiationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/HttpNegotiationTest.php b/tests/Integration/HttpNegotiationTest.php index b4dff9a10..fc08bc069 100644 --- a/tests/Integration/HttpNegotiationTest.php +++ b/tests/Integration/HttpNegotiationTest.php @@ -116,7 +116,7 @@ public function testModernOnlyClientRefusesAHandshakeOnlyServer(): void $client = $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28)->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('without a handshake fallback'); + $this->expectExceptionMessage('without a handshake fallback: "server/discover" was answered with error -32022'); $client->connect($this->transport()); }