Skip to content
Open
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 @@ -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
-----
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 @@ -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.');
}

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

/**
* What a server process that dies leaves behind on the client.
*
* @see Fixture/dead_server.php for the server under test
*/
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');
}
}
38 changes: 38 additions & 0 deletions tests/Integration/Fixture/dead_server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?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}.
*
* `exit` ends the server process in the middle of the call.
*/

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

/**
Expand Down
Loading