Skip to content

Commit 562e0df

Browse files
committed
[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.
1 parent 76778a4 commit 562e0df

7 files changed

Lines changed: 521 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.8.0
66
-----
77

8+
* `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.
89
* 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 `[]`.
910
* 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.
1011
* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.

docs/client.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,45 @@ try {
749749
}
750750
```
751751

752+
### HttpTransportException
753+
754+
Thrown when an `HttpTransport` request gets a non-success HTTP status code
755+
whose body is not a JSON-RPC error response (for example a plain-text error
756+
page). Carries the status code and a snippet of the response body:
757+
758+
```php
759+
use Mcp\Exception\HttpTransportException;
760+
761+
try {
762+
$client->ping();
763+
} catch (HttpTransportException $e) {
764+
echo "Server returned HTTP {$e->getStatusCode()}: {$e->getMessage()}\n";
765+
}
766+
```
767+
768+
When the server does answer with a JSON-RPC error response body (such as
769+
`-32601` method-not-found on a 404, or `-32020` HeaderMismatch on a 400),
770+
that error is dispatched through the normal message path and surfaces as a
771+
`RequestException` instead.
772+
773+
### SessionExpiredException
774+
775+
Thrown when the server answers a request with HTTP 404 and a body that is not
776+
a JSON-RPC error response, meaning it no longer recognizes the current
777+
session. The client clears its local session id and marks itself
778+
un-initialized, so `isConnected()` returns `false`; reconnect to start a new
779+
session:
780+
781+
```php
782+
use Mcp\Exception\SessionExpiredException;
783+
784+
try {
785+
$client->ping();
786+
} catch (SessionExpiredException $e) {
787+
$client->connect($transport); // start a fresh session
788+
}
789+
```
790+
752791
## Complete Example
753792

754793
Here's a comprehensive example demonstrating client usage:

src/Client.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
use Mcp\Client\Protocol;
1717
use Mcp\Client\Transport\TransportInterface;
1818
use Mcp\Exception\ConnectionException;
19+
use Mcp\Exception\HttpTransportException;
1920
use Mcp\Exception\RequestException;
2021
use Mcp\Exception\RuntimeException;
22+
use Mcp\Exception\SessionExpiredException;
2123
use Mcp\Schema\Enum\LoggingLevel;
2224
use Mcp\Schema\Enum\ProtocolVersion;
2325
use Mcp\Schema\Implementation;
@@ -317,7 +319,7 @@ public function sendRootsListChanged(): void
317319
*
318320
* @return Response<mixed>
319321
*
320-
* @throws RequestException|ConnectionException
322+
* @throws RequestException|ConnectionException|SessionExpiredException|HttpTransportException
321323
*/
322324
private function sendRequest(Request $request, ?callable $onProgress = null): Response
323325
{

src/Client/Transport/HttpTransport.php

Lines changed: 137 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
use Http\Discovery\Psr17FactoryDiscovery;
1515
use Http\Discovery\Psr18ClientDiscovery;
1616
use Mcp\Exception\ConnectionException;
17+
use Mcp\Exception\HttpTransportException;
1718
use Mcp\Exception\InvalidArgumentException;
19+
use Mcp\Exception\SessionExpiredException;
1820
use Mcp\Schema\JsonRpc\Error;
1921
use Mcp\Schema\JsonRpc\Response;
2022
use Psr\Http\Client\ClientInterface;
2123
use Psr\Http\Message\RequestFactoryInterface;
24+
use Psr\Http\Message\ResponseInterface;
2225
use Psr\Http\Message\StreamFactoryInterface;
2326
use Psr\Http\Message\StreamInterface;
2427
use Psr\Log\LoggerInterface;
@@ -57,6 +60,12 @@ class HttpTransport extends BaseTransport
5760
*/
5861
public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
5962

63+
/**
64+
* Cap on the characters of a non-JSON-RPC error body included in
65+
* {@see HttpTransportException} messages.
66+
*/
67+
public const MAX_ERROR_BODY_SNIPPET_CHARS = 500;
68+
6069
private readonly int $maxSseBufferBytes;
6170

6271
/**
@@ -120,11 +129,7 @@ public function send(string $data): void
120129
->withHeader('Accept', 'application/json, text/event-stream')
121130
->withBody($this->streamFactory->createStream($data));
122131

123-
if (null !== $this->sessionId) {
124-
$request = $request->withHeader('Mcp-Session-Id', $this->sessionId);
125-
}
126-
127-
foreach ($this->headers as $name => $value) {
132+
foreach ($this->buildHeaders() as $name => $value) {
128133
$request = $request->withHeader($name, $value);
129134
}
130135

@@ -137,6 +142,14 @@ public function send(string $data): void
137142
throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e);
138143
}
139144

145+
$statusCode = $response->getStatusCode();
146+
147+
if ($statusCode < 200 || $statusCode >= 300) {
148+
$this->handleNonSuccessResponse($response, $statusCode);
149+
150+
return;
151+
}
152+
140153
if ($response->hasHeader('Mcp-Session-Id')) {
141154
$this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
142155
$this->logger->debug('Received session ID', ['session_id' => $this->sessionId]);
@@ -155,6 +168,113 @@ public function send(string $data): void
155168
}
156169
}
157170

171+
/**
172+
* Headers shared by every request: the negotiated protocol version, the
173+
* session id once assigned, and the caller-supplied headers.
174+
*
175+
* @return array<string, string>
176+
*/
177+
private function buildHeaders(): array
178+
{
179+
$headers = [];
180+
181+
// Spec: clients MUST echo the negotiated protocol version on every
182+
// request after the initialize handshake. The handshake itself runs
183+
// before a version is negotiated, so the header is omitted for that
184+
// first request and the server falls back to its default version.
185+
$protocolVersion = $this->state?->getProtocolVersion();
186+
if (null !== $protocolVersion) {
187+
$headers['Mcp-Protocol-Version'] = $protocolVersion->value;
188+
}
189+
190+
if (null !== $this->sessionId) {
191+
$headers['Mcp-Session-Id'] = $this->sessionId;
192+
}
193+
194+
foreach ($this->headers as $name => $value) {
195+
$headers[$name] = $value;
196+
}
197+
198+
return $headers;
199+
}
200+
201+
/**
202+
* Handle a non-success response.
203+
*
204+
* The spec asks servers to explain several non-2xx statuses with a
205+
* JSON-RPC error response body (404 + -32601 method-not-found, 400 +
206+
* -32020 HeaderMismatch, 400 + UnsupportedProtocolVersionError listing
207+
* the supported versions). Parse the body first and, when it is a
208+
* JSON-RPC error answering an outstanding request, dispatch it through
209+
* the normal message path so the waiting request resolves with the
210+
* server's error instead of a transport exception. Only bodies that
211+
* cannot be parsed as such an error fall back to the status-derived
212+
* exceptions below.
213+
*
214+
* @throws HttpTransportException|SessionExpiredException
215+
*/
216+
private function handleNonSuccessResponse(ResponseInterface $response, int $statusCode): void
217+
{
218+
$body = $response->getBody()->getContents();
219+
$error = $this->parseJsonRpcError($body);
220+
221+
// The spec asks servers to explain several non-2xx statuses with a
222+
// JSON-RPC error response body (404 + -32601 method-not-found, 400 +
223+
// -32020 HeaderMismatch, 400 + UnsupportedProtocolVersionError
224+
// listing the supported versions). Dispatch such an error through
225+
// the normal message path so the waiting request resolves with the
226+
// server's error instead of a transport exception.
227+
if (null !== $error && null !== $this->state && \array_key_exists($error->getId(), $this->state->getPendingRequests())) {
228+
$this->handleMessage($body);
229+
230+
return;
231+
}
232+
233+
// A 404 on a request carrying a session id, whose body is not a
234+
// JSON-RPC error, means the server has dropped the session: the
235+
// client must re-initialize. Clear the local session id and mark the
236+
// client un-initialized so isConnected() reports false and the
237+
// application can start a new session. A JSON-RPC error body (even
238+
// one that cannot be correlated to a request) does not mean the
239+
// session is gone.
240+
if (404 === $statusCode && null !== $this->sessionId && null === $error) {
241+
$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]);
242+
$this->sessionId = null;
243+
$this->state?->setInitialized(false);
244+
245+
throw new SessionExpiredException('The MCP session no longer exists (HTTP 404); re-initialize the client to start a new session.');
246+
}
247+
248+
// Any other non-success status is a transport-level failure. Reading
249+
// the body here also surfaces plain-text error pages instead of
250+
// silently dropping them and leaving the caller waiting on a timeout.
251+
$snippet = '' === trim($body) ? 'empty body' : mb_substr(trim($body), 0, self::MAX_ERROR_BODY_SNIPPET_CHARS);
252+
253+
throw new HttpTransportException(\sprintf('MCP server returned HTTP %d: %s', $statusCode, $snippet), $statusCode);
254+
}
255+
256+
/**
257+
* Parse a JSON-RPC error response body, or null when the body is not one.
258+
*/
259+
private function parseJsonRpcError(string $body): ?Error
260+
{
261+
try {
262+
$data = json_decode($body, true, flags: \JSON_THROW_ON_ERROR);
263+
} catch (\JsonException $e) {
264+
return null;
265+
}
266+
267+
if (!\is_array($data) || !isset($data['error'])) {
268+
return null;
269+
}
270+
271+
try {
272+
return Error::fromArray($data);
273+
} catch (InvalidArgumentException $e) {
274+
return null;
275+
}
276+
}
277+
158278
/**
159279
* @param McpFiber $fiber
160280
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
@@ -165,25 +285,26 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons
165285
$this->activeProgressCallback = $onProgress;
166286
$fiber->start();
167287

168-
while (!$fiber->isTerminated()) {
169-
$this->tick();
170-
}
171-
172-
$this->activeFiber = null;
173-
$this->activeProgressCallback = null;
174-
$this->activeStream = null;
288+
try {
289+
while (!$fiber->isTerminated()) {
290+
$this->tick();
291+
}
175292

176-
return $fiber->getReturn();
293+
return $fiber->getReturn();
294+
} finally {
295+
$this->activeFiber = null;
296+
$this->activeProgressCallback = null;
297+
$this->activeStream = null;
298+
}
177299
}
178300

179301
public function close(): void
180302
{
181303
if (null !== $this->sessionId) {
182304
try {
183-
$request = $this->requestFactory->createRequest('DELETE', $this->endpoint)
184-
->withHeader('Mcp-Session-Id', $this->sessionId);
305+
$request = $this->requestFactory->createRequest('DELETE', $this->endpoint);
185306

186-
foreach ($this->headers as $name => $value) {
307+
foreach ($this->buildHeaders() as $name => $value) {
187308
$request = $request->withHeader($name, $value);
188309
}
189310

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
/**
15+
* Thrown when the server answers a request with a non-success HTTP status
16+
* code whose body is not a JSON-RPC error response. Carries the status code
17+
* and a snippet of the response body so callers can surface the server-side
18+
* failure instead of waiting on a timeout.
19+
*/
20+
class HttpTransportException extends ConnectionException
21+
{
22+
private readonly int $statusCode;
23+
24+
public function __construct(string $message, int $statusCode, ?\Throwable $previous = null)
25+
{
26+
parent::__construct($message, $statusCode, $previous);
27+
$this->statusCode = $statusCode;
28+
}
29+
30+
public function getStatusCode(): int
31+
{
32+
return $this->statusCode;
33+
}
34+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
/**
15+
* Thrown when the server reports that the current session no longer exists.
16+
*
17+
* An HTTP 404 on a request that carried a session id, whose body is not a
18+
* JSON-RPC error response, means the server has dropped the session. The
19+
* transport clears the local session id and marks the client un-initialized
20+
* so it can re-connect and start a new session.
21+
*/
22+
class SessionExpiredException extends ConnectionException
23+
{
24+
}

0 commit comments

Comments
 (0)