[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:
parent
7b12dc4010
commit
d8cc21a345
11 changed files with 616 additions and 26 deletions
|
|
@ -57,10 +57,32 @@ $client = Client::builder()
|
|||
->setClientInfo('My Application', '1.0.0', 'Description of my client')
|
||||
->setInitTimeout(30) // Seconds to wait for initialization
|
||||
->setRequestTimeout(120) // Seconds to wait for request responses
|
||||
->setMaxRetries(3) // Retry attempts for failed connections
|
||||
->setMaxRetries(3) // Retries for failed connections
|
||||
->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
|
||||
|
||||
Set the client's identity reported to servers during initialization:
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ use Psr\Log\NullLogger;
|
|||
*/
|
||||
class Client
|
||||
{
|
||||
private const RETRY_BASE_DELAY_MS = 100;
|
||||
|
||||
private ?TransportInterface $transport = null;
|
||||
|
||||
public function __construct(
|
||||
|
|
@ -78,16 +80,44 @@ class Client
|
|||
/**
|
||||
* 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
|
||||
{
|
||||
$this->transport = $transport;
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,5 +38,9 @@ class Configuration
|
|||
if ($requestTimeout < 1) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,22 +179,29 @@ class Protocol
|
|||
}
|
||||
|
||||
$this->state->addPendingRequest($requestId, $timeout);
|
||||
$this->sendRequest($request);
|
||||
|
||||
$immediate = $this->state->consumeResponse($requestId);
|
||||
if (null !== $immediate) {
|
||||
$this->logger->debug('Received immediate response', ['id' => $requestId]);
|
||||
try {
|
||||
$this->sendRequest($request);
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
42
tests/Integration/Fixture/request_timeout.php
Normal file
42
tests/Integration/Fixture/request_timeout.php
Normal 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());
|
||||
46
tests/Integration/Fixture/retry.php
Normal file
46
tests/Integration/Fixture/retry.php
Normal 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());
|
||||
|
|
@ -55,26 +55,42 @@ abstract class IntegrationTestCase extends TestCase
|
|||
*/
|
||||
protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client
|
||||
{
|
||||
$script = __DIR__.'/Fixture/'.$fixture.'.php';
|
||||
|
||||
$this->client = ($client ?? $this->clientBuilder())->build();
|
||||
|
||||
try {
|
||||
$this->client->connect(new StdioTransport(
|
||||
command: \PHP_BINARY,
|
||||
args: [$script],
|
||||
// proc_open() replaces the environment rather than adding to it.
|
||||
env: [] === $env ? null : array_merge(getenv(), $env),
|
||||
));
|
||||
$this->client->connect($this->transport($fixture, $env));
|
||||
} catch (ConnectionException $e) {
|
||||
// The transport discards the child's stderr, so a fixture dying on
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$this->client?->disconnect();
|
||||
|
|
|
|||
43
tests/Integration/RequestTimeoutTest.php
Normal file
43
tests/Integration/RequestTimeoutTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
93
tests/Integration/RetryTest.php
Normal file
93
tests/Integration/RetryTest.php
Normal 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
283
tests/Unit/ClientTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue