Description
StreamableHTTPTransport._handle_post_request never notifies the caller of a specific pending request when the server returns a non-2xx status (e.g. 401/403). Instead of surfacing as an error to that request, the caller only ever observes a read timeout — indistinguishable from a genuinely slow/dead server.
Root cause (two-level gap)
1. Missing error path in _handle_post_request (src/mcp/client/streamable_http.py):
async with ctx.client.stream("POST", self.url, ...) as response:
if response.status_code == 202:
...
return
if response.status_code == 404:
...
return
response.raise_for_status() # <-- no try/except around this
...
Compare to its sibling _handle_json_response a few lines below, which does wrap its work in try/except and forwards the failure:
async def _handle_json_response(self, response, read_stream_writer, is_initialization=False):
try:
content = await response.aread()
message = JSONRPCMessage.model_validate_json(content)
...
await read_stream_writer.send(session_message)
except Exception as exc:
logger.exception("Error parsing JSON response")
await read_stream_writer.send(exc)
_handle_post_request's raise_for_status() failure has no equivalent — it propagates out of the fire-and-forget task spawned via tg.start_soon(handle_request_async) in post_writer, uncaught, with no attempt to notify the specific pending request.
2. Even the "obvious" fix doesn't work, because of how read_stream is consumed. I patched in a try/except httpx.HTTPStatusError around raise_for_status() that forwards the exception via await ctx.read_stream_writer.send(exc), mirroring _handle_json_response's pattern exactly. It made no observable difference to callers.
Looking at BaseSession._receive_loop (src/mcp/shared/session.py):
async for message in self._read_stream:
if isinstance(message, Exception):
await self._handle_incoming(message)
elif isinstance(message.message.root, JSONRPCRequest):
...
elif isinstance(message.message.root, JSONRPCNotification):
...
else: # Response or error
await self._handle_response(message)
A bare Exception on the read stream is routed to _handle_incoming — a generic side-channel for unsolicited connection-level issues. Only the else branch (_handle_response) resolves the specific pending future in self._response_streams[request_id] that the original caller is await-ing on. Since a bare exception carries no request id, it can never reach _handle_response, so the caller's pending request is never resolved by this path either — it can only ever time out.
A real fix needs _handle_post_request to construct a proper JSONRPCError (carrying the original request's id) on an HTTP-status failure and route it through the normal response channel, the same way request-validation failures are already handled inside BaseSession._receive_loop:
error_response = JSONRPCError(
jsonrpc="2.0",
id=message.message.root.id,
error=ErrorData(code=..., message=..., data=...),
)
session_message = SessionMessage(message=JSONRPCMessage(error_response))
await self._write_stream.send(session_message)
Why it matters
Any client that wants to distinguish "the server rejected this call" (e.g. expired/invalid credentials — should fail fast, non-retryable) from "the server is slow/unreachable" (should retry) cannot do so over streamable HTTP: both degrade identically as a timeout. We hit this building a Temporal-based SDK that reclassifies MCP failures into a retryable/non-retryable degradation contract — a 401 currently retries maximum_attempts times and only then degrades as a transient failure, burning retries on what should be an immediate non-retryable auth failure.
Repro
Point an MCPServerStreamableHTTP/MCPToolset client at a server that returns HTTP 401 for tools/list or tools/call. Observe:
- No
httpx.HTTPStatusError (or any status-carrying exception) ever reaches the caller.
- The caller's
await only resolves once its own read timeout elapses.
- The eventual exception (a bare timeout) is indistinguishable from a real 504/slow-server case.
Environment
mcp 1.25.0 / 1.28.1 (installed via fastmcp, which pins mcp<2.0)
- Confirmed via source inspection of
mcp/client/streamable_http.py and mcp/shared/session.py, and via two targeted patch experiments (see root cause above) — not yet checked against the mcp 2.0 beta line.
Happy to help test a fix or discuss further.
Description
StreamableHTTPTransport._handle_post_requestnever notifies the caller of a specific pending request when the server returns a non-2xx status (e.g. 401/403). Instead of surfacing as an error to that request, the caller only ever observes a read timeout — indistinguishable from a genuinely slow/dead server.Root cause (two-level gap)
1. Missing error path in
_handle_post_request(src/mcp/client/streamable_http.py):Compare to its sibling
_handle_json_responsea few lines below, which does wrap its work intry/exceptand forwards the failure:_handle_post_request'sraise_for_status()failure has no equivalent — it propagates out of the fire-and-forget task spawned viatg.start_soon(handle_request_async)inpost_writer, uncaught, with no attempt to notify the specific pending request.2. Even the "obvious" fix doesn't work, because of how
read_streamis consumed. I patched in atry/except httpx.HTTPStatusErroraroundraise_for_status()that forwards the exception viaawait ctx.read_stream_writer.send(exc), mirroring_handle_json_response's pattern exactly. It made no observable difference to callers.Looking at
BaseSession._receive_loop(src/mcp/shared/session.py):A bare
Exceptionon the read stream is routed to_handle_incoming— a generic side-channel for unsolicited connection-level issues. Only theelsebranch (_handle_response) resolves the specific pending future inself._response_streams[request_id]that the original caller isawait-ing on. Since a bare exception carries no request id, it can never reach_handle_response, so the caller's pending request is never resolved by this path either — it can only ever time out.A real fix needs
_handle_post_requestto construct a properJSONRPCError(carrying the original request's id) on an HTTP-status failure and route it through the normal response channel, the same way request-validation failures are already handled insideBaseSession._receive_loop:Why it matters
Any client that wants to distinguish "the server rejected this call" (e.g. expired/invalid credentials — should fail fast, non-retryable) from "the server is slow/unreachable" (should retry) cannot do so over streamable HTTP: both degrade identically as a timeout. We hit this building a Temporal-based SDK that reclassifies MCP failures into a retryable/non-retryable degradation contract — a 401 currently retries
maximum_attemptstimes and only then degrades as a transient failure, burning retries on what should be an immediate non-retryable auth failure.Repro
Point an
MCPServerStreamableHTTP/MCPToolsetclient at a server that returns HTTP 401 fortools/listortools/call. Observe:httpx.HTTPStatusError(or any status-carrying exception) ever reaches the caller.awaitonly resolves once its own read timeout elapses.Environment
mcp1.25.0 / 1.28.1 (installed viafastmcp, which pinsmcp<2.0)mcp/client/streamable_http.pyandmcp/shared/session.py, and via two targeted patch experiments (see root cause above) — not yet checked against themcp2.0 beta line.Happy to help test a fix or discuss further.