diff --git a/CHANGELOG.md b/CHANGELOG.md index 6204098c..08e75004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix `ClientLogger` dropping messages below `warning` on `2026-07-28` requests that ask for a lower level with `io.modelcontextprotocol/logLevel`. * Resume an interrupted SSE response in `HttpTransport` with a GET carrying `Last-Event-ID`, honouring the server's `retry` interval. Configure attempts and fallback backoff with `ReconnectPolicy`, the scheduling clock with any PSR-20 `ClockInterface` (default: a monotonic clock). Protocol version `2026-07-28` does not resume streams. A stream that ends without its response and without an event ID now fails the request instead of waiting for its timeout. * Fix a client `onProgress` callback receiving progress for an earlier request that timed out: progress now only reaches the callback of the request whose `progressToken` it carries. +* Fix `Client::isConnected()` staying true after the stdio server process exited, which left later calls failing against the dead process. 0.8.1 ----- diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 5da7762a..93b82d31 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -142,6 +142,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.'); } @@ -276,6 +278,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 client has to connect again before its next call. + $this->state?->setInitialized(false); $this->failPending($this->exitReason(), [self::CONNECTION_LOST => true]); } } diff --git a/tests/Integration/DeadServerTest.php b/tests/Integration/DeadServerTest.php new file mode 100644 index 00000000..f5e6af95 --- /dev/null +++ b/tests/Integration/DeadServerTest.php @@ -0,0 +1,41 @@ +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..fcb9f0d5 --- /dev/null +++ b/tests/Integration/Fixture/dead_server.php @@ -0,0 +1,36 @@ +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 70884cc1..aa753774 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->assertSame('The server process closed its output.', $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()); } /**