value); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_06_18)); $protocol->initialize($config); $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); } #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] public function testDoesNotOfferModernVersionOverHandshake(): void { $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); $protocol = new Protocol(logger: $logger = new CollectingLogger()); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); $protocol->initialize($config); $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); $this->assertSame([[ 'configured' => ProtocolVersion::V2026_07_28->value, 'offered' => ProtocolVersion::latestHandshake()->value, ]], $logger->warnings); } #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] public function testAcceptsHandshakeCounterOffer(): void { $transport = new RecordingTransport(ProtocolVersion::V2024_11_05->value); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); $result = $protocol->initialize($config); $this->assertInstanceOf(Response::class, $result); $this->assertSame(ProtocolVersion::V2024_11_05, $protocol->getState()->getProtocolVersion()); $this->assertTrue($protocol->getState()->isInitialized()); } #[TestDox('fails the handshake when the server answers with a version the SDK cannot speak')] #[DataProvider('provideUnusableCounterOffers')] public function testRejectsUnusableCounterOffer(string $counterOffer): void { $transport = new RecordingTransport($counterOffer); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); $result = $protocol->initialize($config); $this->assertInstanceOf(Error::class, $result); $this->assertStringContainsString($counterOffer, $result->message); $this->assertNull($protocol->getState()->getProtocolVersion()); $this->assertFalse($protocol->getState()->isInitialized()); } /** * @return iterable */ public static function provideUnusableCounterOffers(): iterable { yield 'unknown revision' => ['2099-01-01']; // The modern era has no `initialize`, so a server answering the handshake // with one has produced a connection neither side can actually use. yield 'modern revision' => [ProtocolVersion::V2026_07_28->value]; } #[TestDox('logs and ignores an id-less error response instead of crashing on it')] public function testIgnoresIdLessErrorResponse(): void { $protocol = new Protocol(logger: $logger = new CollectingLogger()); $protocol->processMessage(json_encode([ 'jsonrpc' => MessageInterface::JSONRPC_VERSION, 'error' => ['code' => -32700, 'message' => 'Parse error'], ], \JSON_THROW_ON_ERROR)); $this->assertCount(1, $logger->warnings); } #[TestDox('stores an error response under its id so the pending request can be correlated')] public function testErrorResponseWithIdIsStoredForItsPendingRequest(): void { $protocol = new Protocol(); $protocol->processMessage('{"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found"}}'); $response = $protocol->getState()->consumeResponse(7); $this->assertInstanceOf(Error::class, $response); $this->assertSame(7, $response->getId()); $this->assertSame(Error::METHOD_NOT_FOUND, $response->code); } private function createConfiguration(ProtocolVersion $protocolVersion): Configuration { return new Configuration( clientInfo: new Implementation('client-app', '1.0.0'), capabilities: new ClientCapabilities(), protocolVersion: $protocolVersion, ); } } /** * Transport that answers the `initialize` request inline with a canned * `protocolVersion`, so the handshake resolves without a Fiber round-trip. */ final class RecordingTransport implements TransportInterface { public ?string $offeredVersion = null; private ClientStateInterface $state; public function __construct(private readonly string $counterOffer) { } public function send(string $data): void { /** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */ $message = json_decode($data, true); if ('initialize' !== ($message['method'] ?? null)) { return; } $this->offeredVersion = $message['params']['protocolVersion'] ?? null; $this->state->storeResponse($message['id'], [ 'jsonrpc' => MessageInterface::JSONRPC_VERSION, 'id' => $message['id'], 'result' => [ 'protocolVersion' => $this->counterOffer, 'capabilities' => [], 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], ], ]); } public function setState(ClientStateInterface $state): void { $this->state = $state; } public function connect(): void { } public function close(): void { } public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error { throw new LogicException('Not used in these tests.'); } public function onInitialize(callable $callback): void { } public function onMessage(callable $callback): void { } public function onError(callable $callback): void { } public function onClose(callable $callback): void { } } /** * Logger that keeps the context of every warning, so a silent fallback can be * told apart from one the caller was told about. */ final class CollectingLogger extends AbstractLogger { /** @var list> */ public array $warnings = []; /** * @param string|\Stringable $message * @param array $context */ public function log($level, $message, array $context = []): void { if (LogLevel::WARNING === $level) { $this->warnings[] = $context; } } }