From a93f3a73103acc0fbecf82adfaffb0fd667dbc59 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 03:55:28 +0200 Subject: [PATCH] [Client] Mark the client disconnected when the stdio server exits --- CHANGELOG.md | 1 + src/Client/Transport/StdioTransport.php | 4 ++ tests/Integration/DeadServerTest.php | 46 +++++++++++++++++++ tests/Integration/Fixture/dead_server.php | 38 +++++++++++++++ .../Client/Transport/StdioTransportTest.php | 23 ++++++++++ 5 files changed, 112 insertions(+) create mode 100644 tests/Integration/DeadServerTest.php create mode 100644 tests/Integration/Fixture/dead_server.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e34852a9..a2123096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * 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. * [BC Break] Bump `MessageInterface::PROTOCOL_VERSION` to `2026-07-28`. Use `ProtocolVersion::latestHandshake()` where a handshake revision is needed, e.g. in an `initialize` answer. +* Fix `Client::isConnected()` staying true after the stdio server process exited, which left later calls failing against the dead process. 0.8.0 ----- diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 2108e64e..38ddf350 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -118,6 +118,8 @@ public function send(string $data): void } if (false === @fwrite($this->stdin, $data."\n")) { + $this->state?->setInitialized(false); + throw new ConnectionException('Could not write to the server process; it is no longer running.'); } @@ -246,6 +248,8 @@ private function processInput(): void // 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)) { + // The server is gone: the client has to connect again before its next call. + $this->state?->setInitialized(false); $this->failPending('The server process closed its output; it is no longer running.', [self::CONNECTION_LOST => true]); } } diff --git a/tests/Integration/DeadServerTest.php b/tests/Integration/DeadServerTest.php new file mode 100644 index 00000000..f7d9e563 --- /dev/null +++ b/tests/Integration/DeadServerTest.php @@ -0,0 +1,46 @@ +connect('dead_server', $this->clientBuilder()->setProtocolVersion($version)); + + try { + $client->callTool('exit'); + $this->fail('A call the server dies in must fail.'); + } catch (ExceptionInterface) { + } + + $this->assertFalse($client->isConnected()); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Client is not connected.'); + + $client->callTool('fast'); + } +} diff --git a/tests/Integration/Fixture/dead_server.php b/tests/Integration/Fixture/dead_server.php new file mode 100644 index 00000000..bdf0496c --- /dev/null +++ b/tests/Integration/Fixture/dead_server.php @@ -0,0 +1,38 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (): string { + exit(1); + }, + name: 'exit', + description: 'Ends the server process without answering.', + ) + ->addTool( + static fn (): string => 'quick', + name: 'fast', + description: 'Returns at once.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index 5b85a05e..18f08963 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -86,6 +86,7 @@ public function testClosedOutputFailsPendingRequests(): void { $transport = new StdioTransport(command: 'true'); $state = new ClientState(); + $state->setInitialized(true); $transport->setState($state); $state->addPendingRequest(1, 120); @@ -97,6 +98,28 @@ public function testClosedOutputFailsPendingRequests(): void $this->assertInstanceOf(Error::class, $response); $this->assertStringContainsString('no longer running', $response->message); $this->assertSame([TransportInterface::CONNECTION_LOST => true], $response->data); + $this->assertFalse($state->isInitialized()); + } + + #[TestDox('a server that can no longer be written to leaves the client uninitialized')] + public function testFailedWriteUninitializesTheClient(): void + { + $transport = new StdioTransport(command: 'true'); + $state = new ClientState(); + $state->setInitialized(true); + $transport->setState($state); + + $stdin = fopen('php://temp', 'r'); + $this->assertNotFalse($stdin); + (new \ReflectionProperty($transport, 'stdin'))->setValue($transport, $stdin); + + try { + $transport->send('{"jsonrpc":"2.0","id":1,"method":"ping"}'); + $this->fail('Writing to a server that is gone must fail.'); + } catch (ConnectionException) { + } + + $this->assertFalse($state->isInitialized()); } /**