Skip to content

streamable_http: HTTP status errors (401/403/5xx) on tools/list, tools/call never reach the caller — indistinguishable from a timeout #3091

Description

@afterrburn

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions