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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions