Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
4 changes: 4 additions & 0 deletions src/Client/Transport/StdioTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down Expand Up @@ -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]);
}
}
Expand Down
41 changes: 41 additions & 0 deletions tests/Integration/DeadServerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Integration;

use Mcp\Exception\ConnectionException;
use Mcp\Exception\ExceptionInterface;
use Mcp\Schema\Enum\ProtocolVersion;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;

final class DeadServerTest extends IntegrationTestCase
{
#[DataProvider('provideEras')]
#[TestDox('a server exiting mid-call leaves the client disconnected on $_dataName')]
public function testExitedServerDisconnectsTheClient(ProtocolVersion $version): void
{
$client = $this->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');
}
}
36 changes: 36 additions & 0 deletions tests/Integration/Fixture/dead_server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/*
* Server for {@see \Mcp\Tests\Integration\DeadServerTest}.
*/

use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;

require_once dirname(__DIR__, 3).'/vendor/autoload.php';

Server::builder()
->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());
23 changes: 23 additions & 0 deletions tests/Unit/Client/Transport/StdioTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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());
}

/**
Expand Down
Loading