[Client] Implement setMaxRetries connection retries (#413)

* [Client] Implement setMaxRetries connection retries

* [Client] Reject a negative retry count in Configuration

* [Client] Use a positive init timeout in the retry test

* [Client] Drop redundant docblock note on setMaxRetries

* [Client] Clear the initialized flag on a failed connection

* [Client] Trim comments to the non-obvious ones

* [Client] Cover retries and timeout cleanup with integration tests

The retry fixture counts the processes it was started as, which is what
separates a real retry from a second call into the same server. The timeout
fixture outlives the client's patience and then goes idle, so the next
request runs on a connection the client already gave up on once.
This commit is contained in:
Christopher Hertel 2026-08-15 02:43:50 +02:00 • committed by GitHub
commit d8cc21a345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 616 additions and 26 deletions

View file

@ -57,10 +57,32 @@ $client = Client::builder()
->setClientInfo('My Application', '1.0.0', 'Description of my client') ->setClientInfo('My Application', '1.0.0', 'Description of my client')
->setInitTimeout(30) // Seconds to wait for initialization ->setInitTimeout(30) // Seconds to wait for initialization
->setRequestTimeout(120) // Seconds to wait for request responses ->setRequestTimeout(120) // Seconds to wait for request responses
->setMaxRetries(3) // Retry attempts for failed connections ->setMaxRetries(3) // Retries for failed connections
->build(); ->build();
``` ```
### Connection Retries
`setMaxRetries()` controls how often `connect()` retries a failed connection. It
counts retries rather than attempts, so the default of `3` means one initial
attempt plus up to three retries — four in total — before the `ConnectionException`
of the last attempt is rethrown:
```php
$client = Client::builder()
->setMaxRetries(0) // Fail on the first failed attempt
->build();
```
Between two attempts the transport is closed, so a retry never reuses a
half-established connection: a `StdioTransport` spawns a fresh server process and
an `HttpTransport` discards the session ID of the failed attempt. Each retry is
preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …).
Only the connection handshake is retried. Individual requests such as
`callTool()` are always sent once — retrying them is unsafe as tool calls are not
necessarily idempotent.
### Client Information ### Client Information
Set the client's identity reported to servers during initialization: Set the client's identity reported to servers during initialization:

View file

@ -58,6 +58,8 @@ use Psr\Log\NullLogger;
*/ */
class Client class Client
{ {
private const RETRY_BASE_DELAY_MS = 100;
private ?TransportInterface $transport = null; private ?TransportInterface $transport = null;
public function __construct( public function __construct(
@ -78,16 +80,44 @@ class Client
/** /**
* Connect to an MCP server using the provided transport. * Connect to an MCP server using the provided transport.
* *
* @throws ConnectionException If connection or initialization fails * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}.
*
* @throws ConnectionException If connection or initialization fails on every attempt
*/ */
public function connect(TransportInterface $transport): void public function connect(TransportInterface $transport): void
{ {
$this->transport = $transport; $this->transport = $transport;
$this->protocol->connect($transport, $this->config); $this->protocol->connect($transport, $this->config);
$transport->connect(); $maxAttempts = $this->config->maxRetries + 1;
$this->logger->info('Client connected and initialized'); for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
$transport->connect();
$this->logger->info('Client connected and initialized', ['attempt' => $attempt]);
return;
} catch (ConnectionException $e) {
// initialize() flags the session before sending the initialized
// notification, so a failure in between leaves the flag set.
$this->protocol->getState()->setInitialized(false);
$transport->close();
if ($attempt === $maxAttempts) {
throw $e;
}
$this->logger->warning('Connection attempt failed, retrying', [
'attempt' => $attempt,
'max_attempts' => $maxAttempts,
'exception' => $e,
]);
usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000);
}
}
} }
/** /**

View file

@ -96,7 +96,11 @@ final class Builder
} }
/** /**
* Set maximum retry attempts for failed connections. * Set the number of times a failed connection attempt is retried.
*
* Counts retries, not attempts: 3 means one initial attempt plus up to three
* retries. Pass 0 to fail on the first failure. Only applies to
* {@see Client::connect()}; individual requests are never retried.
*/ */
public function setMaxRetries(int $retries): self public function setMaxRetries(int $retries): self
{ {

View file

@ -38,5 +38,9 @@ class Configuration
if ($requestTimeout < 1) { if ($requestTimeout < 1) {
throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout)); throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout));
} }
if ($maxRetries < 0) {
throw new InvalidArgumentException(\sprintf('The maximum number of retries must be zero or greater, got %d.', $maxRetries));
}
} }
} }

View file

@ -179,22 +179,29 @@ class Protocol
} }
$this->state->addPendingRequest($requestId, $timeout); $this->state->addPendingRequest($requestId, $timeout);
$this->sendRequest($request);
$immediate = $this->state->consumeResponse($requestId); try {
if (null !== $immediate) { $this->sendRequest($request);
$this->logger->debug('Received immediate response', ['id' => $requestId]);
return $immediate; $immediate = $this->state->consumeResponse($requestId);
if (null !== $immediate) {
$this->logger->debug('Received immediate response', ['id' => $requestId]);
return $immediate;
}
$this->logger->debug('Suspending fiber for response', ['id' => $requestId]);
return \Fiber::suspend([
'type' => 'await_response',
'request_id' => $requestId,
'timeout' => $timeout,
]);
} finally {
// Only the response path clears it, so a request that timed out or
// whose send() threw would stay pending and fail every later one.
$this->state->removePendingRequest($requestId);
} }
$this->logger->debug('Suspending fiber for response', ['id' => $requestId]);
return \Fiber::suspend([
'type' => 'await_response',
'request_id' => $requestId,
'timeout' => $timeout,
]);
} }
/** /**

View file

@ -0,0 +1,42 @@
<?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\RequestTimeoutTest}.
*
* `slow` outlives the client's request timeout and then goes idle again, which
* is what lets the test call `fast` on a connection the client has already
* given up on once.
*/
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 {
sleep(2);
return 'late';
},
name: 'slow',
description: 'Answers well after the client stopped waiting.',
)
->addTool(
static fn (): string => 'quick',
name: 'fast',
description: 'Returns at once.',
)
->build()
->run(new StdioTransport());

View file

@ -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.
*/
/*
* Server for {@see \Mcp\Tests\Integration\RetryTest}.
*
* Dies before speaking a frame while the spawn counter is below the configured
* number of failures, so the counter file records how many processes the client
* actually started.
*/
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
require_once dirname(__DIR__, 3).'/vendor/autoload.php';
$counter = getenv('MCP_SPAWN_COUNTER');
if (!is_string($counter) || '' === $counter) {
exit(1);
}
$spawns = 1 + (int) file_get_contents($counter);
file_put_contents($counter, (string) $spawns);
if ($spawns <= (int) getenv('MCP_FAIL_SPAWNS')) {
exit(1);
}
Server::builder()
->setServerInfo('integration-server', '1.0.0')
->addTool(
static fn (): string => 'quick',
name: 'fast',
description: 'Returns at once.',
)
->build()
->run(new StdioTransport());

View file

@ -55,26 +55,42 @@ abstract class IntegrationTestCase extends TestCase
*/ */
protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client
{ {
$script = __DIR__.'/Fixture/'.$fixture.'.php';
$this->client = ($client ?? $this->clientBuilder())->build(); $this->client = ($client ?? $this->clientBuilder())->build();
try { try {
$this->client->connect(new StdioTransport( $this->client->connect($this->transport($fixture, $env));
command: \PHP_BINARY,
args: [$script],
// proc_open() replaces the environment rather than adding to it.
env: [] === $env ? null : array_merge(getenv(), $env),
));
} catch (ConnectionException $e) { } catch (ConnectionException $e) {
// The transport discards the child's stderr, so a fixture dying on // The transport discards the child's stderr, so a fixture dying on
// startup arrives here as a bare timeout. // startup arrives here as a bare timeout.
$this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, $script)); $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, self::script($fixture)));
} }
return $this->client; return $this->client;
} }
/**
* A transport that will spawn a fixture server, without connecting it.
*
* Tests that assert on a failing connection need the transport by itself,
* since {@see self::connect()} turns that failure into a test failure.
*
* @param array<string, string> $env added to the server process environment
*/
protected function transport(string $fixture, array $env = []): StdioTransport
{
return new StdioTransport(
command: \PHP_BINARY,
args: [self::script($fixture)],
// proc_open() replaces the environment rather than adding to it.
env: [] === $env ? null : array_merge(getenv(), $env),
);
}
private static function script(string $fixture): string
{
return __DIR__.'/Fixture/'.$fixture.'.php';
}
protected function tearDown(): void protected function tearDown(): void
{ {
$this->client?->disconnect(); $this->client?->disconnect();

View file

@ -0,0 +1,43 @@
<?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\RequestException;
use PHPUnit\Framework\Attributes\TestDox;
/**
* What a timed-out request leaves behind on the connection.
*
* @see Fixture/request_timeout.php for the server under test
*/
final class RequestTimeoutTest extends IntegrationTestCase
{
#[TestDox('an abandoned response is not handed to the next request')]
public function testTimedOutRequestDoesNotLeakIntoTheNext(): void
{
$client = $this->connect('request_timeout', $this->clientBuilder()->setRequestTimeout(1));
try {
$client->callTool('slow');
$this->fail('The slow tool should have outlived the request timeout.');
} catch (RequestException) {
}
// The server is still executing the abandoned call and cannot answer
// anything until it returns, so the next request has to wait it out.
sleep(2);
// Without the pending request being cleared this answers 'late': the
// response the client gave up on, handed to the request after it.
$this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null);
}
}

View file

@ -0,0 +1,93 @@
<?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 PHPUnit\Framework\Attributes\TestDox;
/**
* Connection retries, counted in processes rather than calls.
*
* A retry is only worth anything if it starts a fresh server, which is what the
* spawn counter pins down: the fixture bumps it on every start, so the test can
* tell a genuine second process from a second call into the same one.
*
* A failed attempt costs the init timeout, so these are the slowest tests here.
*
* @see Fixture/retry.php for the server under test
*/
final class RetryTest extends IntegrationTestCase
{
private string $counter;
protected function setUp(): void
{
$counter = tempnam(sys_get_temp_dir(), 'mcp-spawns');
if (false === $counter) {
$this->fail('Could not create the spawn counter file.');
}
$this->counter = $counter;
file_put_contents($this->counter, '0');
}
protected function tearDown(): void
{
parent::tearDown();
@unlink($this->counter);
}
#[TestDox('a failed attempt is retried against a newly spawned server')]
public function testRetrySpawnsAnotherServer(): void
{
$client = $this->connect(
'retry',
$this->clientBuilder()->setInitTimeout(2)->setMaxRetries(1),
$this->environment(failing: 1),
);
$this->assertSame(2, $this->spawns());
$this->assertTrue($client->isConnected());
$this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null);
}
#[TestDox('no retries means the first failure is the last word')]
public function testRetriesCanBeDisabled(): void
{
$client = $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(0)->build();
try {
$client->connect($this->transport('retry', $this->environment(failing: 1)));
$this->fail('Connecting should not have succeeded.');
} catch (ConnectionException) {
$this->assertSame(1, $this->spawns());
}
}
/**
* @return array<string, string>
*/
private function environment(int $failing): array
{
return [
'MCP_SPAWN_COUNTER' => $this->counter,
'MCP_FAIL_SPAWNS' => (string) $failing,
];
}
private function spawns(): int
{
return (int) file_get_contents($this->counter);
}
}

283
tests/Unit/ClientTest.php Normal file
View file

@ -0,0 +1,283 @@
<?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\Unit;
use Mcp\Client;
use Mcp\Client\Builder;
use Mcp\Client\Transport\BaseTransport;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class ClientTest extends TestCase
{
#[TestDox('builder() returns a Builder instance')]
public function testBuilderReturnsBuilderInstance(): void
{
$this->assertInstanceOf(Builder::class, Client::builder());
}
#[TestDox('connect() succeeds on the first attempt without retrying')]
public function testConnectSucceedsWithoutRetrying(): void
{
$transport = new FakeTransport([FakeTransport::ACCEPT]);
$client = Client::builder()->build();
$client->connect($transport);
$this->assertSame(1, $transport->connectCalls);
$this->assertSame(0, $transport->closeCalls);
$this->assertTrue($client->isConnected());
}
#[TestDox('connect() retries a failed attempt and succeeds on a later one')]
public function testConnectRetriesUntilItSucceeds(): void
{
$transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::ACCEPT]);
$client = Client::builder()->setMaxRetries(3)->build();
$client->connect($transport);
$this->assertSame(3, $transport->connectCalls);
$this->assertTrue($client->isConnected());
$this->assertSame('Test Server', $client->getServerInfo()?->name);
}
#[TestDox('connect() closes the transport between two attempts')]
public function testConnectClosesTransportBetweenAttempts(): void
{
$transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]);
$client = Client::builder()->setMaxRetries(1)->build();
$client->connect($transport);
$this->assertSame(2, $transport->connectCalls);
$this->assertSame(1, $transport->closeCalls, 'the failed attempt must be cleaned up before the retry');
}
#[TestDox('connect() rethrows once the retries are exhausted')]
public function testConnectThrowsWhenRetriesAreExhausted(): void
{
$transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::REJECT]);
$client = Client::builder()->setMaxRetries(2)->build();
try {
$client->connect($transport);
$this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class));
} catch (ConnectionException) {
// Expected.
}
$this->assertSame(3, $transport->connectCalls, 'one initial attempt plus two retries');
$this->assertSame(3, $transport->closeCalls, 'the last attempt must be cleaned up as well');
$this->assertFalse($client->isConnected());
}
#[TestDox('setMaxRetries(0) disables retrying')]
public function testZeroRetriesAttemptsToConnectOnce(): void
{
$transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]);
$client = Client::builder()->setMaxRetries(0)->build();
$this->expectException(ConnectionException::class);
try {
$client->connect($transport);
} finally {
$this->assertSame(1, $transport->connectCalls);
}
}
#[TestDox('a negative retry count is rejected')]
public function testNegativeRetryCountIsRejected(): void
{
$builder = Client::builder()->setMaxRetries(-1);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The maximum number of retries must be zero or greater, got -1.');
$builder->build();
}
#[TestDox('a connection breaking after the handshake result does not leave the client connected')]
public function testFailureAfterHandshakeResultDoesNotLeaveClientConnected(): void
{
$transport = new FakeTransport([FakeTransport::BREAK_AFTER_ACCEPT]);
$client = Client::builder()->setMaxRetries(0)->build();
try {
$client->connect($transport);
$this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class));
} catch (ConnectionException) {
// Expected.
}
$this->assertFalse($client->isConnected());
}
#[TestDox('a timed out attempt does not leave state behind that fails the retry')]
public function testTimedOutAttemptDoesNotPoisonTheRetry(): void
{
// The shortest allowed init timeout, so this waits out about a second.
$transport = new FakeTransport([FakeTransport::IGNORE, FakeTransport::ACCEPT]);
$client = Client::builder()->setInitTimeout(1)->setMaxRetries(1)->build();
$client->connect($transport);
$this->assertSame(2, $transport->connectCalls);
$this->assertTrue($client->isConnected());
}
}
/**
* Transport whose connection attempts succeed or fail on command.
*
* Requests are answered from the polling loop rather than from send(), the way
* a stdio transport does, with each connect() call answering according to the
* next configured outcome.
*
* @phpstan-import-type McpFiber from TransportInterface
*/
final class FakeTransport extends BaseTransport
{
/** The initialize request is answered with a result. */
public const ACCEPT = 'accept';
/** The initialize request is answered with a JSON-RPC error. */
public const REJECT = 'reject';
/** The initialize request is not answered at all and has to time out. */
public const IGNORE = 'ignore';
/** The initialize request is answered, but the connection breaks right after. */
public const BREAK_AFTER_ACCEPT = 'break_after_accept';
public int $connectCalls = 0;
public int $closeCalls = 0;
private string $outcome = self::ACCEPT;
/** @var list<string> Answers not yet delivered to the client */
private array $outbox = [];
/**
* @param list<self::ACCEPT|self::REJECT|self::IGNORE|self::BREAK_AFTER_ACCEPT> $attempts How each successive connect() call behaves
*/
public function __construct(private array $attempts = [self::ACCEPT])
{
parent::__construct();
}
public function connect(): void
{
++$this->connectCalls;
$this->outcome = array_shift($this->attempts) ?? self::ACCEPT;
$result = $this->runRequest(new \Fiber(fn () => $this->handleInitialize()));
if ($result instanceof Error) {
throw new ConnectionException('Initialization failed: '.$result->message);
}
}
public function send(string $data): void
{
if (self::IGNORE === $this->outcome) {
return;
}
$message = json_decode($data, true, 512, \JSON_THROW_ON_ERROR);
if (!isset($message['id'])) {
if (self::BREAK_AFTER_ACCEPT === $this->outcome) {
throw new ConnectionException('Connection lost');
}
return; // A notification, nothing to answer.
}
$answer = self::REJECT === $this->outcome
? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']]
: ['result' => [
'protocolVersion' => ProtocolVersion::V2025_11_25->value,
'capabilities' => [],
'serverInfo' => ['name' => 'Test Server', 'version' => '1.0.0'],
]];
$this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id']] + $answer, \JSON_THROW_ON_ERROR);
}
public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error
{
$fiber->start();
// Polls at the same 1ms interval as the real transports, so a request
// left unanswered runs into its timeout in real time.
for ($poll = 0; !$fiber->isTerminated(); ++$poll) {
if ($poll > 5000) {
throw new \LogicException('The fiber never terminated, no pending request became resolvable.');
}
foreach ($this->outbox as $answer) {
$this->handleMessage($answer);
}
$this->outbox = [];
$this->resumeFiber($fiber);
usleep(1000);
}
return $fiber->getReturn();
}
public function close(): void
{
++$this->closeCalls;
$this->handleClose('Transport closed');
}
/**
* @param McpFiber $fiber
*/
private function resumeFiber(\Fiber $fiber): void
{
if (!$fiber->isSuspended() || null === $this->state) {
return;
}
foreach ($this->state->getPendingRequests() as $pending) {
$response = $this->state->consumeResponse($pending['request_id']);
if (null !== $response) {
$fiber->resume($response);
return;
}
if (time() - $pending['timestamp'] >= $pending['timeout']) {
$fiber->resume(Error::forInternalError('Request timed out', $pending['request_id']));
return;
}
}
}
}