Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/Client/Exception/HttpTransportException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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\Client\Exception;

/**
* Thrown when the server answers a request with a non-success HTTP status
* code. Carries the status code and a snippet of the response body so callers
* can surface the server-side failure instead of waiting on a timeout.
*/
class HttpTransportException extends \Mcp\Exception\Exception
{
private readonly int $statusCode;

public function __construct(string $message, int $statusCode, ?\Throwable $previous = null)
{
parent::__construct($message, $statusCode, $previous);
$this->statusCode = $statusCode;
}

public function getStatusCode(): int
{
return $this->statusCode;
}
}
21 changes: 21 additions & 0 deletions src/Client/Exception/SessionExpiredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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\Client\Exception;

/**
* Thrown when the server reports that the current session no longer exists
* (HTTP 404 on a request that carried a session id). The transport clears the
* local session id so the application can re-initialize and start a new one.
*/
class SessionExpiredException extends \Mcp\Exception\Exception
{
}
34 changes: 34 additions & 0 deletions src/Client/Transport/HttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Mcp\Client\Exception\HttpTransportException;
use Mcp\Client\Exception\SessionExpiredException;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Error;
Expand Down Expand Up @@ -124,6 +126,15 @@ public function send(string $data): void
$request = $request->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);
}
Expand All @@ -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]);
Expand Down
139 changes: 139 additions & 0 deletions tests/Unit/Client/Transport/HttpTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down