diff --git a/src/Client/Exception/HttpTransportException.php b/src/Client/Exception/HttpTransportException.php new file mode 100644 index 00000000..fbc6c5eb --- /dev/null +++ b/src/Client/Exception/HttpTransportException.php @@ -0,0 +1,33 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Client/Exception/SessionExpiredException.php b/src/Client/Exception/SessionExpiredException.php new file mode 100644 index 00000000..e3182788 --- /dev/null +++ b/src/Client/Exception/SessionExpiredException.php @@ -0,0 +1,21 @@ +withHeader('Mcp-Session-Id', $this->sessionId); } + // 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) { + $request = $request->withHeader('MCP-Protocol-Version', $protocolVersion->value); + } + foreach ($this->headers as $name => $value) { $request = $request->withHeader($name, $value); } @@ -137,6 +148,29 @@ public function send(string $data): void throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e); } + $statusCode = $response->getStatusCode(); + + // A 404 on a request carrying a session id means the server has dropped + // the session: the client must re-initialize. Clear the local session id + // and surface the failure instead of parsing the error body as a normal + // message. + if (404 === $statusCode && null !== $this->sessionId) { + $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; + + 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. + if ($statusCode < 200 || $statusCode >= 300) { + $body = $response->getBody()->getContents(); + $snippet = '' === trim($body) ? 'empty body' : substr(trim($body), 0, 500); + + throw new HttpTransportException(\sprintf('MCP server returned HTTP %d: %s', $statusCode, $snippet), $statusCode); + } + if ($response->hasHeader('Mcp-Session-Id')) { $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 6c6119e1..b70f9799 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -12,9 +12,12 @@ namespace Mcp\Tests\Unit\Client\Transport; use Mcp\Client; +use Mcp\Client\Exception\HttpTransportException; +use Mcp\Client\Exception\SessionExpiredException; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -153,6 +156,137 @@ public function testRejectsNonPositiveCap(): void $this->createTransport(maxSseBufferBytes: 0); } + #[TestDox('HTTP 404 with a session id clears the session and throws a session-expired error')] + public function test404WithSessionClearsSessionAndThrowsSessionExpiredException(): void + { + $httpClient = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(404, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Session not found"}}'); + } + }; + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + $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.'); + } + + #[TestDox('HTTP 404 without a session id is a plain transport error')] + public function test404WithoutSessionThrowsHttpTransportException(): void + { + $httpClient = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(404, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Not found"}}'); + } + }; + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + + 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 + { + $httpClient = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(500, ['Content-Type' => 'text/plain'], 'Internal Server Error'); + } + }; + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + + 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 200 application/json response is dispatched normally')] + public function test200JsonResponseIsHandledNormally(): void + { + $httpClient = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response(200, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}'); + } + }; + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + $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 = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + $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 = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + + $transport->send('{"jsonrpc":"2.0","method":"ping","id":1}'); + + $this->assertSame('', $httpClient->protocolVersionHeader); + } + private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport { return new HttpTransport( @@ -169,6 +303,11 @@ private function setActiveStream(HttpTransport $transport, StreamInterface $stre (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);