From 562e0df5d3eca708a99a22e215ed21aa36727f23 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Mon, 17 Aug 2026 22:53:18 +0800 Subject: [PATCH] [Client] Parse JSON-RPC error bodies before falling back to status codes HttpTransport::send() now checks the HTTP status of every response. On a non-2xx status it parses the body first: when the body is a JSON-RPC error answering an outstanding request (404 + -32601 method-not-found, 400 + -32020 HeaderMismatch, 400 + UnsupportedProtocolVersionError listing the supported versions), it is dispatched through the normal message path so the request resolves with the server's error (surfacing as RequestException) instead of a transport exception. Bodies that cannot be parsed fall back to two new exception classes, both in the Mcp\Exception namespace and extending ConnectionException so a failing handshake keeps being retried by Client::connect(): - SessionExpiredException: HTTP 404 on a request carrying a session id whose body is not a JSON-RPC error means the server dropped the session. The transport clears the local session id, marks the client un-initialized so isConnected() reports false, and throws so the application re-initializes. - HttpTransportException: any other non-success status, carrying the status code and a snippet of the response body. The transport also sends the negotiated Mcp-Protocol-Version header on every request, including the DELETE that closes a session (shared buildHeaders()), and runRequest() releases its active fiber, progress callback, and stream in a finally block even when a request throws. --- CHANGELOG.md | 1 + docs/client.md | 39 +++ src/Client.php | 4 +- src/Client/Transport/HttpTransport.php | 153 +++++++++- src/Exception/HttpTransportException.php | 34 +++ src/Exception/SessionExpiredException.php | 24 ++ .../Client/Transport/HttpTransportTest.php | 286 +++++++++++++++++- 7 files changed, 521 insertions(+), 20 deletions(-) create mode 100644 src/Exception/HttpTransportException.php create mode 100644 src/Exception/SessionExpiredException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f449b5..972f824e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* `HttpTransport` now inspects the HTTP status of every response. A non-2xx status whose body is a JSON-RPC error response (404 + `-32601` method-not-found, 400 + `-32020` HeaderMismatch, 400 + UnsupportedProtocolVersionError) is dispatched through the normal message path and surfaces as a `RequestException`, while bodies that cannot be parsed fall back to two new exception classes. `Mcp\Exception\SessionExpiredException` covers a 404 on a request carrying a session id whose body is not a JSON-RPC error (the server has dropped the session; the client clears its session id and marks itself un-initialized), and `Mcp\Exception\HttpTransportException` covers any other non-success status, carrying the status code and a snippet of the body. Both extend `ConnectionException`, so a failing handshake keeps being retried by `Client::connect()`. The transport also sends the negotiated protocol version header on every request, including the `DELETE` that closes the session, and `runRequest()` no longer leaks the active fiber and progress state when a request throws. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. diff --git a/docs/client.md b/docs/client.md index 962397c8..54178884 100644 --- a/docs/client.md +++ b/docs/client.md @@ -749,6 +749,45 @@ try { } ``` +### HttpTransportException + +Thrown when an `HttpTransport` request gets a non-success HTTP status code +whose body is not a JSON-RPC error response (for example a plain-text error +page). Carries the status code and a snippet of the response body: + +```php +use Mcp\Exception\HttpTransportException; + +try { + $client->ping(); +} catch (HttpTransportException $e) { + echo "Server returned HTTP {$e->getStatusCode()}: {$e->getMessage()}\n"; +} +``` + +When the server does answer with a JSON-RPC error response body (such as +`-32601` method-not-found on a 404, or `-32020` HeaderMismatch on a 400), +that error is dispatched through the normal message path and surfaces as a +`RequestException` instead. + +### SessionExpiredException + +Thrown when the server answers a request with HTTP 404 and a body that is not +a JSON-RPC error response, meaning it no longer recognizes the current +session. The client clears its local session id and marks itself +un-initialized, so `isConnected()` returns `false`; reconnect to start a new +session: + +```php +use Mcp\Exception\SessionExpiredException; + +try { + $client->ping(); +} catch (SessionExpiredException $e) { + $client->connect($transport); // start a fresh session +} +``` + ## Complete Example Here's a comprehensive example demonstrating client usage: diff --git a/src/Client.php b/src/Client.php index ed5abc6f..1cee9e2a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -16,8 +16,10 @@ use Mcp\Client\Protocol; use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; +use Mcp\Exception\HttpTransportException; use Mcp\Exception\RequestException; use Mcp\Exception\RuntimeException; +use Mcp\Exception\SessionExpiredException; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; @@ -317,7 +319,7 @@ public function sendRootsListChanged(): void * * @return Response * - * @throws RequestException|ConnectionException + * @throws RequestException|ConnectionException|SessionExpiredException|HttpTransportException */ private function sendRequest(Request $request, ?callable $onProgress = null): Response { diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index ddb662f7..25437206 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -14,11 +14,14 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\ConnectionException; +use Mcp\Exception\HttpTransportException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\SessionExpiredException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; @@ -57,6 +60,12 @@ class HttpTransport extends BaseTransport */ public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; + /** + * Cap on the characters of a non-JSON-RPC error body included in + * {@see HttpTransportException} messages. + */ + public const MAX_ERROR_BODY_SNIPPET_CHARS = 500; + private readonly int $maxSseBufferBytes; /** @@ -120,11 +129,7 @@ public function send(string $data): void ->withHeader('Accept', 'application/json, text/event-stream') ->withBody($this->streamFactory->createStream($data)); - if (null !== $this->sessionId) { - $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); - } - - foreach ($this->headers as $name => $value) { + foreach ($this->buildHeaders() as $name => $value) { $request = $request->withHeader($name, $value); } @@ -137,6 +142,14 @@ public function send(string $data): void throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e); } + $statusCode = $response->getStatusCode(); + + if ($statusCode < 200 || $statusCode >= 300) { + $this->handleNonSuccessResponse($response, $statusCode); + + return; + } + if ($response->hasHeader('Mcp-Session-Id')) { $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); @@ -155,6 +168,113 @@ public function send(string $data): void } } + /** + * Headers shared by every request: the negotiated protocol version, the + * session id once assigned, and the caller-supplied headers. + * + * @return array + */ + private function buildHeaders(): array + { + $headers = []; + + // Spec: clients MUST echo the negotiated protocol version on every + // request after the initialize handshake. The handshake itself runs + // before a version is negotiated, so the header is omitted for that + // first request and the server falls back to its default version. + $protocolVersion = $this->state?->getProtocolVersion(); + if (null !== $protocolVersion) { + $headers['Mcp-Protocol-Version'] = $protocolVersion->value; + } + + if (null !== $this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + foreach ($this->headers as $name => $value) { + $headers[$name] = $value; + } + + return $headers; + } + + /** + * Handle a non-success response. + * + * The spec asks servers to explain several non-2xx statuses with a + * JSON-RPC error response body (404 + -32601 method-not-found, 400 + + * -32020 HeaderMismatch, 400 + UnsupportedProtocolVersionError listing + * the supported versions). Parse the body first and, when it is a + * JSON-RPC error answering an outstanding request, dispatch it through + * the normal message path so the waiting request resolves with the + * server's error instead of a transport exception. Only bodies that + * cannot be parsed as such an error fall back to the status-derived + * exceptions below. + * + * @throws HttpTransportException|SessionExpiredException + */ + private function handleNonSuccessResponse(ResponseInterface $response, int $statusCode): void + { + $body = $response->getBody()->getContents(); + $error = $this->parseJsonRpcError($body); + + // The spec asks servers to explain several non-2xx statuses with a + // JSON-RPC error response body (404 + -32601 method-not-found, 400 + + // -32020 HeaderMismatch, 400 + UnsupportedProtocolVersionError + // listing the supported versions). Dispatch such an error through + // the normal message path so the waiting request resolves with the + // server's error instead of a transport exception. + if (null !== $error && null !== $this->state && \array_key_exists($error->getId(), $this->state->getPendingRequests())) { + $this->handleMessage($body); + + return; + } + + // A 404 on a request carrying a session id, whose body is not a + // JSON-RPC error, means the server has dropped the session: the + // client must re-initialize. Clear the local session id and mark the + // client un-initialized so isConnected() reports false and the + // application can start a new session. A JSON-RPC error body (even + // one that cannot be correlated to a request) does not mean the + // session is gone. + if (404 === $statusCode && null !== $this->sessionId && null === $error) { + $this->logger->warning('Server no longer knows the current session (HTTP 404); clearing the session id so the client re-initializes.', ['session_id' => $this->sessionId]); + $this->sessionId = null; + $this->state?->setInitialized(false); + + throw new SessionExpiredException('The MCP session no longer exists (HTTP 404); re-initialize the client to start a new session.'); + } + + // Any other non-success status is a transport-level failure. Reading + // the body here also surfaces plain-text error pages instead of + // silently dropping them and leaving the caller waiting on a timeout. + $snippet = '' === trim($body) ? 'empty body' : mb_substr(trim($body), 0, self::MAX_ERROR_BODY_SNIPPET_CHARS); + + throw new HttpTransportException(\sprintf('MCP server returned HTTP %d: %s', $statusCode, $snippet), $statusCode); + } + + /** + * Parse a JSON-RPC error response body, or null when the body is not one. + */ + private function parseJsonRpcError(string $body): ?Error + { + try { + $data = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return null; + } + + if (!\is_array($data) || !isset($data['error'])) { + return null; + } + + try { + return Error::fromArray($data); + } catch (InvalidArgumentException $e) { + return null; + } + } + /** * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress @@ -165,25 +285,26 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons $this->activeProgressCallback = $onProgress; $fiber->start(); - while (!$fiber->isTerminated()) { - $this->tick(); - } - - $this->activeFiber = null; - $this->activeProgressCallback = null; - $this->activeStream = null; + try { + while (!$fiber->isTerminated()) { + $this->tick(); + } - return $fiber->getReturn(); + return $fiber->getReturn(); + } finally { + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + } } public function close(): void { if (null !== $this->sessionId) { try { - $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) - ->withHeader('Mcp-Session-Id', $this->sessionId); + $request = $this->requestFactory->createRequest('DELETE', $this->endpoint); - foreach ($this->headers as $name => $value) { + foreach ($this->buildHeaders() as $name => $value) { $request = $request->withHeader($name, $value); } diff --git a/src/Exception/HttpTransportException.php b/src/Exception/HttpTransportException.php new file mode 100644 index 00000000..23b1dba0 --- /dev/null +++ b/src/Exception/HttpTransportException.php @@ -0,0 +1,34 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Exception/SessionExpiredException.php b/src/Exception/SessionExpiredException.php new file mode 100644 index 00000000..caaf22c9 --- /dev/null +++ b/src/Exception/SessionExpiredException.php @@ -0,0 +1,24 @@ + [": ping\n\nevent: message\ndata: %s\n\n"]; } + /** + * @return iterable + */ + public static function emptyBodyStatusProvider(): iterable + { + yield '202 Accepted' => [202]; + yield '204 No Content' => [204]; + } + #[DataProvider('frameProvider')] #[TestDox('initialization succeeds for an SSE response framed as: $_dataName')] public function testInitializeParsesSseFraming(string $frame): void @@ -88,7 +105,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface ->setInitTimeout(1) ->build(); - $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + $client->connect($this->createTransport($httpClient)); $this->assertTrue($client->isConnected()); $this->assertSame('test-server', $client->getServerInfo()?->name); @@ -153,22 +170,285 @@ public function testRejectsNonPositiveCap(): void $this->createTransport(maxSseBufferBytes: 0); } - private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport + #[TestDox('HTTP 404 with a session id and a non-JSON-RPC body clears the session and throws a session-expired error')] + public function test404WithSessionClearsSessionAndThrowsSessionExpiredException(): void + { + $transport = $this->createTransport($this->createStubClient(404, ['Content-Type' => 'text/plain'], 'Not Found')); + $state = new ClientState(); + $state->setInitialized(true); + $transport->setState($state); + $this->setSessionId($transport, 'abc-123'); + + try { + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + $this->fail('Expected SessionExpiredException to be thrown.'); + } catch (SessionExpiredException $e) { + $this->assertStringContainsString('404', $e->getMessage()); + } + + $this->assertNull($this->readPrivate($transport, 'sessionId'), 'The session id must be cleared so the client can re-initialize.'); + $this->assertFalse($state->isInitialized(), 'The client must be marked un-initialized so isConnected() reports false.'); + } + + #[TestDox('HTTP 404 without a session id is a plain transport error')] + public function test404WithoutSessionThrowsHttpTransportException(): void + { + $transport = $this->createTransport($this->createStubClient(404, ['Content-Type' => 'text/plain'], 'Not Found')); + + try { + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + $this->fail('Expected HttpTransportException to be thrown.'); + } catch (HttpTransportException $e) { + $this->assertSame(404, $e->getStatusCode()); + } + } + + #[TestDox('a non-success status throws a transport error carrying the status and a body snippet')] + public function testNonSuccessStatusThrowsHttpTransportException(): void + { + $transport = $this->createTransport($this->createStubClient(500, ['Content-Type' => 'text/plain'], 'Internal Server Error')); + + try { + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + $this->fail('Expected HttpTransportException to be thrown.'); + } catch (HttpTransportException $e) { + $this->assertSame(500, $e->getStatusCode()); + $this->assertStringContainsString('500', $e->getMessage()); + $this->assertStringContainsString('Internal Server Error', $e->getMessage()); + } + } + + #[TestDox('a 404 whose body is a JSON-RPC error is dispatched as a message and keeps the session id')] + public function test404JsonRpcErrorBodyIsDispatchedAndPreservesSession(): void + { + $body = '{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}'; + $transport = $this->createTransport($this->createStubClient(404, ['Content-Type' => 'application/json'], $body)); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + $this->setSessionId($transport, 'abc-123'); + + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame([$body], $messages, 'A JSON-RPC error body must be dispatched through the message handler.'); + $this->assertSame('abc-123', $this->readPrivate($transport, 'sessionId'), 'A JSON-RPC error body must not clear a live session.'); + } + + #[TestDox('a non-2xx JSON-RPC error body resolves the request with the error instead of throwing a transport exception')] + public function testNonSuccessJsonRpcErrorBodyResolvesRequestWithError(): void + { + $transport = $this->createTransport($this->createStubClient(404, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}')); + $protocol = new Protocol(); + $protocol->connect($transport, $this->createConfiguration()); + + $fiber = new \Fiber(fn () => $protocol->request(new PingRequest(), 30)); + $result = $transport->runRequest($fiber); + + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(Error::METHOD_NOT_FOUND, $result->code); + } + + #[TestDox('a non-404 failure keeps the session id')] + public function testSessionIdIsPreservedOnNon404Failure(): void + { + $transport = $this->createTransport($this->createStubClient(500, ['Content-Type' => 'text/plain'], 'Internal Server Error')); + $this->setSessionId($transport, 'abc-123'); + + try { + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + $this->fail('Expected HttpTransportException to be thrown.'); + } catch (HttpTransportException $e) { + $this->assertSame(500, $e->getStatusCode()); + } + + $this->assertSame('abc-123', $this->readPrivate($transport, 'sessionId'), 'A non-404 failure must not clear the session id.'); + } + + #[DataProvider('emptyBodyStatusProvider')] + #[TestDox('a $_dataName response is accepted without a body')] + public function test2xxWithEmptyBodyIsAccepted(int $statusCode): void + { + $transport = $this->createTransport($this->createStubClient($statusCode, ['Content-Type' => 'application/json'], '')); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame([], $messages); + } + + #[TestDox('a 200 application/json response is dispatched normally')] + public function test200JsonResponseIsHandledNormally(): void + { + $transport = $this->createTransport($this->createStubClient(200, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}')); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame(['{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}'], $messages); + } + + #[TestDox('the Mcp-Protocol-Version header is sent once a protocol version is negotiated')] + public function testSendsProtocolVersionHeaderWhenNegotiated(): void + { + $httpClient = new class implements ClientInterface { + public ?string $protocolVersionHeader = null; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->protocolVersionHeader = $request->getHeaderLine('Mcp-Protocol-Version'); + + return new Response(200, ['Content-Type' => 'application/json']); + } + }; + + $transport = $this->createTransport($httpClient); + $state = new ClientState(); + $state->setProtocolVersion(ProtocolVersion::V2025_11_25); + $transport->setState($state); + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame('2025-11-25', $httpClient->protocolVersionHeader); + } + + #[TestDox('the Mcp-Protocol-Version header is omitted before a protocol version is negotiated')] + public function testOmitsProtocolVersionHeaderBeforeNegotiation(): void + { + $httpClient = new class implements ClientInterface { + public ?string $protocolVersionHeader = 'unset'; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->protocolVersionHeader = $request->getHeaderLine('Mcp-Protocol-Version'); + + return new Response(200, ['Content-Type' => 'application/json']); + } + }; + + $transport = $this->createTransport($httpClient); + $transport->setState(new ClientState()); // version not yet negotiated + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame('', $httpClient->protocolVersionHeader); + } + + #[TestDox('closing the session sends the negotiated protocol version header')] + public function testCloseSendsProtocolVersionHeader(): void + { + $httpClient = new class implements ClientInterface { + public ?string $method = null; + public ?string $protocolVersionHeader = null; + public ?string $sessionIdHeader = null; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->method = $request->getMethod(); + $this->protocolVersionHeader = $request->getHeaderLine('Mcp-Protocol-Version'); + $this->sessionIdHeader = $request->getHeaderLine('Mcp-Session-Id'); + + return new Response(200, ['Content-Type' => 'application/json']); + } + }; + + $transport = $this->createTransport($httpClient); + $state = new ClientState(); + $state->setProtocolVersion(ProtocolVersion::V2025_11_25); + $transport->setState($state); + $this->setSessionId($transport, 'abc-123'); + + $transport->close(); + + $this->assertSame('DELETE', $httpClient->method); + $this->assertSame('2025-11-25', $httpClient->protocolVersionHeader); + $this->assertSame('abc-123', $httpClient->sessionIdHeader); + } + + #[TestDox('runRequest clears its active state when a request throws')] + public function testRunRequestCleansUpWhenSendThrows(): void + { + $transport = $this->createTransport($this->createStubClient(500, ['Content-Type' => 'text/plain'], 'Internal Server Error')); + $protocol = new Protocol(); + $protocol->connect($transport, $this->createConfiguration()); + + $fiber = new \Fiber(fn () => $protocol->request(new PingRequest(), 30)); + + try { + $transport->runRequest($fiber); + $this->fail('Expected HttpTransportException to be thrown.'); + } catch (HttpTransportException $e) { + $this->assertSame(500, $e->getStatusCode()); + } + + $this->assertNull($this->readPrivate($transport, 'activeFiber'), 'The fiber must be released when a request throws.'); + $this->assertNull($this->readPrivate($transport, 'activeProgressCallback'), 'The progress callback must be released when a request throws.'); + $this->assertNull($this->readPrivate($transport, 'activeStream'), 'The stream must be released when a request throws.'); + } + + private function createTransport(?ClientInterface $httpClient = null, int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport { return new HttpTransport( endpoint: 'https://example.test/mcp', - httpClient: $this->createMock(ClientInterface::class), + httpClient: $httpClient ?? $this->createMock(ClientInterface::class), requestFactory: $this->factory, streamFactory: $this->factory, maxSseBufferBytes: $maxSseBufferBytes, ); } + /** + * @param array $headers + */ + private function createStubClient(int $statusCode, array $headers = [], string $body = ''): ClientInterface + { + return new class($statusCode, $headers, $body) implements ClientInterface { + /** + * @param array $headers + */ + public function __construct( + private readonly int $statusCode, + private readonly array $headers, + private readonly string $body, + ) { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response($this->statusCode, $this->headers, $this->body); + } + }; + } + + private function createConfiguration(): Configuration + { + return new Configuration( + clientInfo: new Implementation('client-app', '1.0.0'), + capabilities: new ClientCapabilities(), + protocolVersion: ProtocolVersion::V2025_11_25, + ); + } + private function setActiveStream(HttpTransport $transport, StreamInterface $stream): void { (new \ReflectionProperty($transport, 'activeStream'))->setValue($transport, $stream); } + private function setSessionId(HttpTransport $transport, ?string $sessionId): void + { + (new \ReflectionProperty($transport, 'sessionId'))->setValue($transport, $sessionId); + } + private function invokeProcessSseStream(HttpTransport $transport): void { (new \ReflectionMethod($transport, 'processSSEStream'))->invoke($transport);