diff --git a/docs/client.md b/docs/client.md index f17b8555..8843a80b 100644 --- a/docs/client.md +++ b/docs/client.md @@ -240,6 +240,13 @@ $transport = new HttpTransport( - `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) - `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) - `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxSseBufferBytes` (int): Maximum bytes buffered for one incomplete SSE event (default: 8 MiB) +- `maxReconnectAttempts` (int): Maximum GET attempts to resume an interrupted SSE stream (default: 5) +- `initialReconnectDelayMs` (int): Initial fallback delay when the server sends no `retry` field (default: 1000 ms) +- `maxReconnectDelayMs` (int): Cap for the fallback exponential backoff (default: 10000 ms) +- `clock` (callable|null): Optional monotonic millisecond clock, primarily for deterministic tests + +When an SSE connection closes before its JSON-RPC response arrives, the transport resumes it with a GET request carrying the latest `Last-Event-ID`. A server-provided SSE `retry` value controls the delay; otherwise the fallback delay doubles from 1 second up to 10 seconds. Completed requests and explicitly closed transports are not reconnected. **PSR-18 Auto-Discovery:** diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index b5499e85..5193325d 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -55,25 +55,68 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa /** @var string Buffer for incomplete SSE data */ private string $sseBuffer = ''; + /** The request whose POST response opened the current SSE stream. */ + private int|string|null $sseRequestId = null; + + /** Whether the current SSE stream delivered that request's final response. */ + private bool $sseRequestCompleted = false; + + /** Last event ID received on the current logical SSE stream. */ + private ?string $lastEventId = null; + + /** Reconnection delay requested by the server, in milliseconds. */ + private ?int $serverRetryMs = null; + + /** Number of GET resumption attempts made for the current logical stream. */ + private int $reconnectAttempts = 0; + + /** Monotonic timestamp, in milliseconds, when the next resumption may start. */ + private ?float $nextReconnectAtMs = null; + + /** Protocol version copied onto transport-internal GET requests when available. */ + private ?string $protocolVersionHeader = null; + + /** @var \Closure(): float */ + private readonly \Closure $clock; + /** * Default cap on the bytes buffered while waiting for a complete SSE event. */ public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; + public const DEFAULT_MAX_RECONNECT_ATTEMPTS = 5; + + public const DEFAULT_INITIAL_RECONNECT_DELAY_MS = 1000; + + public const DEFAULT_MAX_RECONNECT_DELAY_MS = 10000; + private readonly int $maxSseBufferBytes; + private readonly int $maxReconnectAttempts; + + private readonly int $initialReconnectDelayMs; + + private readonly int $maxReconnectDelayMs; + /** - * @param string $endpoint The MCP server endpoint URL - * @param array $headers Additional headers to send - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) - * @param int $maxSseBufferBytes Maximum bytes buffered while waiting for a complete - * SSE event. A server that never sends the "\n\n" event - * delimiter would otherwise grow the buffer without bound - * and exhaust client memory; reaching the cap aborts the - * stream instead. Raise it for servers that legitimately - * emit single events larger than the default. + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param int $maxSseBufferBytes Maximum bytes buffered while waiting for a complete + * SSE event. A server that never sends the "\n\n" event + * delimiter would otherwise grow the buffer without bound + * and exhaust client memory; reaching the cap aborts the + * stream instead. Raise it for servers that legitimately + * emit single events larger than the default. + * @param int $maxReconnectAttempts Maximum GET attempts used to resume an interrupted SSE + * stream. Zero disables resumption. + * @param int $initialReconnectDelayMs Initial reconnect delay when the server has not sent + * an SSE `retry` field. It doubles after each failed attempt. + * @param int $maxReconnectDelayMs Maximum client-selected reconnect delay. A server-provided + * `retry` value is not capped. + * @param (callable(): float)|null $clock monotonic millisecond clock; primarily useful for tests */ public function __construct( private readonly string $endpoint, @@ -83,6 +126,10 @@ public function __construct( ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, int $maxSseBufferBytes = self::DEFAULT_MAX_SSE_BUFFER_BYTES, + int $maxReconnectAttempts = self::DEFAULT_MAX_RECONNECT_ATTEMPTS, + int $initialReconnectDelayMs = self::DEFAULT_INITIAL_RECONNECT_DELAY_MS, + int $maxReconnectDelayMs = self::DEFAULT_MAX_RECONNECT_DELAY_MS, + ?callable $clock = null, ) { parent::__construct($logger); @@ -90,7 +137,25 @@ public function __construct( throw new InvalidArgumentException(\sprintf('The maximum SSE buffer size must be a positive number of bytes, got %d.', $maxSseBufferBytes)); } + if ($maxReconnectAttempts < 0) { + throw new InvalidArgumentException(\sprintf('The maximum number of SSE reconnect attempts must be zero or greater, got %d.', $maxReconnectAttempts)); + } + + if ($initialReconnectDelayMs < 0) { + throw new InvalidArgumentException(\sprintf('The initial SSE reconnect delay must be zero or greater, got %d milliseconds.', $initialReconnectDelayMs)); + } + + if ($maxReconnectDelayMs < $initialReconnectDelayMs) { + throw new InvalidArgumentException(\sprintf('The maximum SSE reconnect delay must be at least the initial delay of %d milliseconds, got %d.', $initialReconnectDelayMs, $maxReconnectDelayMs)); + } + $this->maxSseBufferBytes = $maxSseBufferBytes; + $this->maxReconnectAttempts = $maxReconnectAttempts; + $this->initialReconnectDelayMs = $initialReconnectDelayMs; + $this->maxReconnectDelayMs = $maxReconnectDelayMs; + $this->clock = null === $clock + ? static fn (): float => hrtime(true) / 1_000_000 + : \Closure::fromCallable($clock); $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -98,6 +163,7 @@ public function __construct( public function connect(): void { + $this->resetSseState(); $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); $this->activeFiber->start(); @@ -108,6 +174,7 @@ public function connect(): void $result = $this->activeFiber->getReturn(); $this->activeFiber = null; + $this->resetSseState(); if ($result instanceof Error) { throw new ConnectionException('Initialization failed: '.$result->message); @@ -143,6 +210,11 @@ public function send(string $data): void $request = $request->withHeader($name, $value); } + $protocolVersion = $request->getHeaderLine('MCP-Protocol-Version'); + if ('' !== $protocolVersion) { + $this->protocolVersionHeader = $protocolVersion; + } + $this->logger->debug('Sending HTTP request', ['data' => $data]); try { @@ -160,8 +232,7 @@ public function send(string $data): void $contentType = strtolower($response->getHeaderLine('Content-Type')); if (str_contains($contentType, 'text/event-stream')) { - $this->activeStream = $response->getBody(); - $this->sseBuffer = ''; + $this->startSseStream($response->getBody(), $this->requestIdFrom($data)); } elseif (str_contains($contentType, 'application/json')) { $body = $response->getBody()->getContents(); if (!empty($body)) { @@ -186,7 +257,7 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons $this->activeFiber = null; $this->activeProgressCallback = null; - $this->activeStream = null; + $this->resetSseState(); return $fiber->getReturn(); } @@ -210,7 +281,8 @@ public function close(): void } $this->sessionId = null; - $this->activeStream = null; + $this->protocolVersionHeader = null; + $this->resetSseState(); $this->handleClose('Transport closed'); } @@ -240,6 +312,7 @@ private function tick(): void $this->processSSEStream(); $this->processProgress(); $this->processFiber(); + $this->processSseReconnect(); usleep(1000); // 1ms } @@ -253,17 +326,23 @@ private function processSSEStream(): void return; } - if (!$this->activeStream->eof()) { - $chunk = $this->activeStream->read(4096); - if ('' !== $chunk) { - if (\strlen($this->sseBuffer) + \strlen($chunk) > $this->maxSseBufferBytes) { - $this->abortSseStream(\sprintf('buffered %d bytes without a complete event, exceeding the %d byte limit', \strlen($this->sseBuffer) + \strlen($chunk), $this->maxSseBufferBytes)); + try { + if (!$this->activeStream->eof()) { + $chunk = $this->activeStream->read(4096); + if ('' !== $chunk) { + if (\strlen($this->sseBuffer) + \strlen($chunk) > $this->maxSseBufferBytes) { + $this->abortSseStream(\sprintf('buffered %d bytes without a complete event, exceeding the %d byte limit', \strlen($this->sseBuffer) + \strlen($chunk), $this->maxSseBufferBytes)); - return; - } + return; + } - $this->sseBuffer .= $chunk; + $this->sseBuffer .= $chunk; + } } + } catch (\Throwable $e) { + $this->handleSseStreamInterruption($e); + + return; } while (null !== ($event = $this->extractSSEEvent())) { @@ -272,7 +351,15 @@ private function processSSEStream(): void } } - if ($this->activeStream->eof()) { + try { + $streamEnded = $this->activeStream->eof(); + } catch (\Throwable $e) { + $this->handleSseStreamInterruption($e); + + return; + } + + if ($streamEnded) { // The stream ended without a trailing blank line: dispatch what is left. if (!empty(trim($this->sseBuffer))) { $this->processSSEEvent($this->sseBuffer); @@ -280,6 +367,197 @@ private function processSSEStream(): void $this->sseBuffer = ''; $this->activeStream = null; + + if (!$this->sseRequestCompleted) { + $this->scheduleSseReconnect(); + } else { + $this->nextReconnectAtMs = null; + } + } + } + + private function handleSseStreamInterruption(\Throwable $error): void + { + $this->activeStream = null; + $this->sseBuffer = ''; + $this->handleError($error); + $this->scheduleSseReconnect(); + } + + /** + * Resume an interrupted logical SSE stream once its delay has elapsed. + */ + private function processSseReconnect(): void + { + if (null === $this->nextReconnectAtMs) { + return; + } + + if (!$this->isSseStreamExpected()) { + $this->nextReconnectAtMs = null; + + return; + } + + if ($this->nowMilliseconds() < $this->nextReconnectAtMs) { + return; + } + + $this->nextReconnectAtMs = null; + ++$this->reconnectAttempts; + + $request = $this->requestFactory->createRequest('GET', $this->endpoint) + ->withHeader('Accept', 'text/event-stream'); + + if (null !== $this->sessionId) { + $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); + } + + if (null !== $this->lastEventId && '' !== $this->lastEventId) { + $request = $request->withHeader('Last-Event-ID', $this->lastEventId); + } + + if (null !== $this->protocolVersionHeader) { + $request = $request->withHeader('MCP-Protocol-Version', $this->protocolVersionHeader); + } + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->logger->info('Reconnecting SSE stream', [ + 'attempt' => $this->reconnectAttempts, + 'max_attempts' => $this->maxReconnectAttempts, + 'last_event_id' => $this->lastEventId, + 'session_id' => $this->sessionId, + ]); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + $this->handleError($e); + $this->scheduleSseReconnect(); + + return; + } + + if ($response->hasHeader('Mcp-Session-Id')) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + } + + $contentType = strtolower($response->getHeaderLine('Content-Type')); + if (!str_contains($contentType, 'text/event-stream')) { + $this->logger->warning('SSE reconnect did not return an event stream', [ + 'attempt' => $this->reconnectAttempts, + 'status' => $response->getStatusCode(), + 'content_type' => $contentType, + ]); + $this->scheduleSseReconnect(); + + return; + } + + $this->activeStream = $response->getBody(); + $this->sseBuffer = ''; + $this->sseRequestCompleted = false; + } + + /** + * Arrange the next GET resumption without blocking request timeout handling. + */ + private function scheduleSseReconnect(): void + { + if (!$this->isSseStreamExpected()) { + $this->nextReconnectAtMs = null; + + return; + } + + if (null === $this->lastEventId || '' === $this->lastEventId) { + $this->failSseStream('SSE stream ended before the request completed and did not provide an event ID for resumption'); + + return; + } + + if ($this->reconnectAttempts >= $this->maxReconnectAttempts) { + $this->failSseStream(\sprintf('SSE stream ended before the request completed and the maximum of %d reconnect attempts was reached', $this->maxReconnectAttempts)); + + return; + } + + $delayMs = $this->serverRetryMs ?? $this->backoffDelayMs(); + $this->nextReconnectAtMs = $this->nowMilliseconds() + $delayMs; + + $this->logger->info('SSE stream disconnected; scheduling reconnect', [ + 'attempt' => $this->reconnectAttempts + 1, + 'max_attempts' => $this->maxReconnectAttempts, + 'delay_ms' => $delayMs, + 'last_event_id' => $this->lastEventId, + 'session_id' => $this->sessionId, + ]); + } + + private function isSseStreamExpected(): bool + { + if ($this->sseRequestCompleted) { + return false; + } + + if (null === $this->sseRequestId) { + return null !== $this->sessionId; + } + + if (null === $this->state) { + return false; + } + + foreach ($this->state->getPendingRequests() as $pending) { + if ($pending['request_id'] === $this->sseRequestId) { + return true; + } + } + + return false; + } + + private function backoffDelayMs(): int + { + $delay = $this->initialReconnectDelayMs; + + for ($attempt = 0; $attempt < $this->reconnectAttempts && $delay < $this->maxReconnectDelayMs; ++$attempt) { + $delay = $delay > intdiv($this->maxReconnectDelayMs, 2) + ? $this->maxReconnectDelayMs + : $delay * 2; + } + + return $delay; + } + + private function failSseStream(string $reason): void + { + $this->activeStream = null; + $this->sseBuffer = ''; + $this->nextReconnectAtMs = null; + + $this->logger->warning($reason, [ + 'attempts' => $this->reconnectAttempts, + 'last_event_id' => $this->lastEventId, + 'session_id' => $this->sessionId, + ]); + + if (null === $this->state || null === $this->sseRequestId) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + if ($pending['request_id'] !== $this->sseRequestId) { + continue; + } + + $error = Error::forInternalError($reason, $this->sseRequestId); + $this->state->storeResponse($this->sseRequestId, $error->jsonSerialize()); + + return; } } @@ -294,6 +572,7 @@ private function abortSseStream(string $reason): void $bufferedBytes = \strlen($this->sseBuffer); $this->sseBuffer = ''; $this->activeStream = null; + $this->nextReconnectAtMs = null; $this->logger->warning('Aborting SSE stream: '.$reason, [ 'session_id' => $this->sessionId, @@ -348,17 +627,149 @@ private function extractSSEEvent(): ?string */ private function processSSEEvent(string $event): void { - $data = ''; + // Receiving an event proves that the GET reopened the stream. A later + // polling close starts a fresh retry sequence; only consecutive + // failures to reopen the stream consume the attempt cap. + $this->reconnectAttempts = 0; + $dataLines = []; foreach (preg_split("/\r\n|\r|\n/", $event) ?: [] as $line) { - if (str_starts_with($line, 'data:')) { - $data .= trim(substr($line, 5)); + if ('' === $line || str_starts_with($line, ':')) { + continue; + } + + $separator = strpos($line, ':'); + if (false === $separator) { + $field = $line; + $value = ''; + } else { + $field = substr($line, 0, $separator); + $value = substr($line, $separator + 1); + + if (str_starts_with($value, ' ')) { + $value = substr($value, 1); + } } + + if ('data' === $field) { + $dataLines[] = $value; + } elseif ('id' === $field && !str_contains($value, "\0")) { + $this->lastEventId = $value; + } elseif ('retry' === $field && null !== ($retryMs = $this->retryMilliseconds($value))) { + $this->serverRetryMs = $retryMs; + } + } + + if ([] === $dataLines) { + return; } - if (!empty($data)) { - $this->handleMessage($data); + $data = implode("\n", $dataLines); + if ('' === $data) { + return; } + + if ($this->isResponseForCurrentSseRequest($data)) { + $this->sseRequestCompleted = true; + } + + $this->handleMessage($data); + } + + private function retryMilliseconds(string $value): ?int + { + if (1 !== preg_match('/^[0-9]+$/D', $value)) { + return null; + } + + $normalized = ltrim($value, '0'); + if ('' === $normalized) { + return 0; + } + + $maximum = (string) \PHP_INT_MAX; + if (\strlen($normalized) > \strlen($maximum) || (\strlen($normalized) === \strlen($maximum) && strcmp($normalized, $maximum) > 0)) { + return null; + } + + return (int) $normalized; + } + + private function isResponseForCurrentSseRequest(string $data): bool + { + if (null === $this->sseRequestId) { + return false; + } + + try { + $decoded = json_decode($data, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return false; + } + + if (!\is_array($decoded)) { + return false; + } + + $messages = array_is_list($decoded) ? $decoded : [$decoded]; + + foreach ($messages as $message) { + if (!\is_array($message) || isset($message['method'])) { + continue; + } + + if (($message['id'] ?? null) === $this->sseRequestId && (\array_key_exists('result', $message) || \array_key_exists('error', $message))) { + return true; + } + } + + return false; + } + + private function startSseStream(StreamInterface $stream, int|string|null $requestId): void + { + $this->activeStream = $stream; + $this->sseBuffer = ''; + $this->sseRequestId = $requestId; + $this->sseRequestCompleted = false; + $this->lastEventId = null; + $this->serverRetryMs = null; + $this->reconnectAttempts = 0; + $this->nextReconnectAtMs = null; + } + + private function resetSseState(): void + { + $this->activeStream = null; + $this->sseBuffer = ''; + $this->sseRequestId = null; + $this->sseRequestCompleted = false; + $this->lastEventId = null; + $this->serverRetryMs = null; + $this->reconnectAttempts = 0; + $this->nextReconnectAtMs = null; + } + + private function requestIdFrom(string $payload): int|string|null + { + try { + $decoded = json_decode($payload, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!\is_array($decoded)) { + return null; + } + + $requestId = $decoded['id'] ?? null; + + return \is_int($requestId) || \is_string($requestId) ? $requestId : null; + } + + private function nowMilliseconds(): float + { + return (float) ($this->clock)(); } /** diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 9832f47d..4a2c544d 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -102,6 +102,11 @@ public function handle(Request $request): Response $logger->info('Called tool: add_numbers'); break; + case 'sse-retry': + $client->callTool('test_reconnection', []); + $logger->info('Completed tool call after SSE reconnection'); + break; + case 'elicitation-sep1034-client-defaults': $toolName = $toolsResult->tools[0]->name ?? 'test_client_elicitation_defaults'; $client->callTool($toolName, []); diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index c57855e8..3c2fa853 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,6 +1,5 @@ client: - elicitation-sep1034-client-defaults - - sse-retry - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 6c6119e1..3ead0d97 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -145,6 +145,214 @@ public function testWellFormedEventsStillParse(): void $this->assertSame(['hello', 'world'], $messages); } + #[TestDox('an interrupted SSE request resumes with its latest event ID after the server retry delay')] + public function testReconnectsWithLatestEventIdAfterServerDelay(): void + { + $now = 1_000.0; + $response = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'result' => ['ok' => true], + ], \JSON_THROW_ON_ERROR); + $httpClient = new RecordingHttpClient([ + new Response(200, [ + 'Content-Type' => 'text/event-stream', + 'Mcp-Session-Id' => 'session-1', + ], "id: first\ndata:\n\nid: latest\nretry: 500\ndata:\n\n"), + new Response(200, ['Content-Type' => 'text/event-stream'], "id: final\ndata: {$response}\n\n"), + ]); + $transport = $this->createTransport( + httpClient: $httpClient, + headers: [ + 'Authorization' => 'Bearer secret', + 'MCP-Protocol-Version' => '2025-11-25', + ], + clock: static function () use (&$now): float { + return $now; + }, + ); + $state = new ClientState(); + $state->addPendingRequest(7, 30); + $transport->setState($state); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $transport->send(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + ], \JSON_THROW_ON_ERROR)); + $this->invokeProcessSseStream($transport); + + $this->assertSame(1_500.0, $this->readPrivate($transport, 'nextReconnectAtMs')); + + $this->invokeProcessSseReconnect($transport); + $this->assertCount(1, $httpClient->requests); + + $now = 1_500.0; + $this->invokeProcessSseReconnect($transport); + + $this->assertCount(2, $httpClient->requests); + $request = $httpClient->requests[1]; + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('text/event-stream', $request->getHeaderLine('Accept')); + $this->assertSame('session-1', $request->getHeaderLine('Mcp-Session-Id')); + $this->assertSame('latest', $request->getHeaderLine('Last-Event-ID')); + $this->assertSame('Bearer secret', $request->getHeaderLine('Authorization')); + $this->assertSame('2025-11-25', $request->getHeaderLine('MCP-Protocol-Version')); + + $this->invokeProcessSseStream($transport); + + $this->assertSame([$response], $messages); + $this->assertNull($this->readPrivate($transport, 'nextReconnectAtMs')); + } + + #[TestDox('fallback reconnect delays double, cap at ten seconds, and stop after five attempts')] + public function testReconnectBackoffAndAttemptLimit(): void + { + $now = 0.0; + $responses = [new Response(200, ['Content-Type' => 'text/event-stream'], "id: event-0\ndata:\n\n")]; + + for ($attempt = 1; $attempt <= 5; ++$attempt) { + $responses[] = new Response(503); + } + + $httpClient = new RecordingHttpClient($responses); + $transport = $this->createTransport( + httpClient: $httpClient, + clock: static function () use (&$now): float { + return $now; + }, + ); + $state = new ClientState(); + $state->addPendingRequest(1, 60); + $transport->setState($state); + $transport->send('{"jsonrpc":"2.0","id":1,"method":"tools/call"}'); + + $this->invokeProcessSseStream($transport); + foreach ([1_000, 2_000, 4_000, 8_000, 10_000] as $attempt => $delay) { + $this->assertSame($now + $delay, $this->readPrivate($transport, 'nextReconnectAtMs')); + $now += $delay; + $this->invokeProcessSseReconnect($transport); + + $request = $httpClient->requests[$attempt + 1]; + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('event-0', $request->getHeaderLine('Last-Event-ID')); + } + + $this->assertCount(6, $httpClient->requests, 'one POST plus exactly five reconnect attempts'); + $error = $state->consumeResponse(1); + $this->assertInstanceOf(Error::class, $error); + $this->assertStringContainsString('maximum of 5 reconnect attempts', $error->message); + } + + #[TestDox('a request that is no longer pending cancels its scheduled reconnect')] + public function testCompletedOrCancelledRequestDoesNotReconnect(): void + { + $now = 0.0; + $httpClient = new RecordingHttpClient([ + new Response(200, ['Content-Type' => 'text/event-stream'], "id: resumable\ndata:\n\n"), + ]); + $transport = $this->createTransport( + httpClient: $httpClient, + clock: static function () use (&$now): float { + return $now; + }, + ); + $state = new ClientState(); + $state->addPendingRequest(9, 30); + $transport->setState($state); + $transport->send('{"jsonrpc":"2.0","id":9,"method":"tools/call"}'); + $this->invokeProcessSseStream($transport); + + $state->removePendingRequest(9); + $now = 10_000.0; + $this->invokeProcessSseReconnect($transport); + + $this->assertCount(1, $httpClient->requests); + $this->assertNull($this->readPrivate($transport, 'nextReconnectAtMs')); + } + + #[TestDox('receiving the final response before EOF is a normal end and does not reconnect')] + public function testFinalResponseDoesNotReconnect(): void + { + $now = 0.0; + $payload = '{"jsonrpc":"2.0","id":3,"result":{"ok":true}}'; + $httpClient = new RecordingHttpClient([ + new Response(200, ['Content-Type' => 'text/event-stream'], "id: complete\ndata: {$payload}\n\n"), + ]); + $transport = $this->createTransport( + httpClient: $httpClient, + clock: static function () use (&$now): float { + return $now; + }, + ); + $state = new ClientState(); + $state->addPendingRequest(3, 30); + $transport->setState($state); + $transport->send('{"jsonrpc":"2.0","id":3,"method":"tools/call"}'); + + $this->invokeProcessSseStream($transport); + $now = 60_000.0; + $this->invokeProcessSseReconnect($transport); + + $this->assertCount(1, $httpClient->requests); + $this->assertNull($this->readPrivate($transport, 'nextReconnectAtMs')); + } + + #[TestDox('closing the transport cancels a scheduled reconnect')] + public function testCloseCancelsScheduledReconnect(): void + { + $now = 0.0; + $httpClient = new RecordingHttpClient([ + new Response(200, [ + 'Content-Type' => 'text/event-stream', + 'Mcp-Session-Id' => 'session-1', + ], "id: resumable\ndata:\n\n"), + new Response(204), + ]); + $transport = $this->createTransport( + httpClient: $httpClient, + clock: static function () use (&$now): float { + return $now; + }, + ); + $state = new ClientState(); + $state->addPendingRequest(4, 30); + $transport->setState($state); + $transport->send('{"jsonrpc":"2.0","id":4,"method":"tools/call"}'); + $this->invokeProcessSseStream($transport); + + $transport->close(); + $now = 60_000.0; + $this->invokeProcessSseReconnect($transport); + + $this->assertCount(2, $httpClient->requests); + $this->assertSame('DELETE', $httpClient->requests[1]->getMethod()); + $this->assertSame('session-1', $httpClient->requests[1]->getHeaderLine('Mcp-Session-Id')); + } + + #[TestDox('an unfinished stream without an event ID fails instead of waiting for the request timeout')] + public function testUnresumableEofFailsPendingRequest(): void + { + $httpClient = new RecordingHttpClient([ + new Response(200, ['Content-Type' => 'text/event-stream'], ": keep-alive\n\n"), + ]); + $transport = $this->createTransport(httpClient: $httpClient); + $state = new ClientState(); + $state->addPendingRequest(5, 30); + $transport->setState($state); + $transport->send('{"jsonrpc":"2.0","id":5,"method":"tools/call"}'); + + $this->invokeProcessSseStream($transport); + + $error = $state->consumeResponse(5); + $this->assertInstanceOf(Error::class, $error); + $this->assertStringContainsString('did not provide an event ID', $error->message); + } + #[TestDox('the buffer cap must be a positive number of bytes')] public function testRejectsNonPositiveCap(): void { @@ -153,14 +361,46 @@ public function testRejectsNonPositiveCap(): void $this->createTransport(maxSseBufferBytes: 0); } - private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport + #[TestDox('the reconnect policy rejects a negative attempt count')] + public function testRejectsNegativeReconnectAttempts(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createTransport(maxReconnectAttempts: -1); + } + + #[TestDox('the maximum reconnect delay cannot be lower than the initial delay')] + public function testRejectsReconnectDelayCapBelowInitialDelay(): void { + $this->expectException(InvalidArgumentException::class); + + $this->createTransport(initialReconnectDelayMs: 1_001, maxReconnectDelayMs: 1_000); + } + + /** + * @param array $headers + * @param (callable(): float)|null $clock + */ + private function createTransport( + int $maxSseBufferBytes = 8 * 1024 * 1024, + ?ClientInterface $httpClient = null, + array $headers = [], + int $maxReconnectAttempts = 5, + int $initialReconnectDelayMs = 1_000, + int $maxReconnectDelayMs = 10_000, + ?callable $clock = null, + ): HttpTransport { return new HttpTransport( endpoint: 'https://example.test/mcp', - httpClient: $this->createMock(ClientInterface::class), + headers: $headers, + httpClient: $httpClient ?? $this->createMock(ClientInterface::class), requestFactory: $this->factory, streamFactory: $this->factory, maxSseBufferBytes: $maxSseBufferBytes, + maxReconnectAttempts: $maxReconnectAttempts, + initialReconnectDelayMs: $initialReconnectDelayMs, + maxReconnectDelayMs: $maxReconnectDelayMs, + clock: $clock, ); } @@ -174,8 +414,45 @@ private function invokeProcessSseStream(HttpTransport $transport): void (new \ReflectionMethod($transport, 'processSSEStream'))->invoke($transport); } + private function invokeProcessSseReconnect(HttpTransport $transport): void + { + (new \ReflectionMethod($transport, 'processSseReconnect'))->invoke($transport); + } + private function readPrivate(HttpTransport $transport, string $property): mixed { return (new \ReflectionProperty($transport, $property))->getValue($transport); } } + +/** @internal */ +final class RecordingHttpClient implements ClientInterface +{ + /** @var list */ + public array $requests = []; + + /** @var list */ + private array $responses; + + /** @param list $responses */ + public function __construct(array $responses) + { + $this->responses = $responses; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + $response = array_shift($this->responses); + + if ($response instanceof \Throwable) { + throw $response; + } + + if (!$response instanceof ResponseInterface) { + throw new \LogicException('No HTTP response was queued for request '.$request->getMethod().'.'); + } + + return $response; + } +}