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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
39 changes: 39 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -317,7 +319,7 @@ public function sendRootsListChanged(): void
*
* @return Response<mixed>
*
* @throws RequestException|ConnectionException
* @throws RequestException|ConnectionException|SessionExpiredException|HttpTransportException
*/
private function sendRequest(Request $request, ?callable $onProgress = null): Response
{
Expand Down
153 changes: 137 additions & 16 deletions src/Client/Transport/HttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}

Expand All @@ -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]);
Expand All @@ -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<string, string>
*/
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
Expand All @@ -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);
}

Expand Down
34 changes: 34 additions & 0 deletions src/Exception/HttpTransportException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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\Exception;

/**
* Thrown when the server answers a request with a non-success HTTP status
* code whose body is not a JSON-RPC error response. 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 ConnectionException
{
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;
}
}
24 changes: 24 additions & 0 deletions src/Exception/SessionExpiredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?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\Exception;

/**
* Thrown when the server reports that the current session no longer exists.
*
* An HTTP 404 on a request that carried a session id, whose body is not a
* JSON-RPC error response, means the server has dropped the session. The
* transport clears the local session id and marks the client un-initialized
* so it can re-connect and start a new session.
*/
class SessionExpiredException extends ConnectionException
{
}
Loading
Loading