Repository navigation
streamable_http: HTTP status errors (401/403/5xx) on tools/list, tools/call never reach the caller — indistinguishable from a timeout #3091
Description
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
awaitonly resolves once its own read timeout elapses. - The eventual exception (a bare timeout) is indistinguishable from a real 504/slow-server case.
Environment
mcp1.25.0 / 1.28.1 (installed viafastmcp, which pinsmcp<2.0)- Confirmed via source inspection of
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.
Activity
himanshu-commits commentedon Jul 13, 2026
Hi @afterrburn, thanks for the detailed writeup. I'm going to pick this up — planning to catch the raise_for_status() failure in _handle_post_request and route a JSONRPCError (with the original request id) through read_stream_writer, mirroring the existing SSE-parse-error pattern in the same file.
One thing I want to confirm before implementing: should this target the current v1.x line, or has this already changed in the 2.0 beta? Will open a draft PR shortly either way.
afterrburn commentedon Jul 13, 2026
Thanks for picking this up! To answer your question: target v1.x — this is already fixed in the 2.0 beta line.
I pulled the
mcp-2.0.0b1wheel from PyPI and diffedmcp/client/streamable_http.pyagainst 1.25.0/1.28.1._handle_post_requestno longer calls a bareresponse.raise_for_status(). It now explicitly branches onresponse.status_code >= 400, builds aJSONRPCErrorcarrying the original request'smessage.id, and sends it as aSessionMessage(not a bareException) viactx.read_stream_writer.send(...)— routing it through the normal response-correlation path instead of the generic_handle_incomingside-channel. It even goes a step further: if the non-2xx response body already contains a spec-compliantJSONRPCError, it reuses that instead of synthesizing one from the status code.So your planned approach (catch the failure, route a
JSONRPCErrorwith the original request id throughread_stream_writer, mirroring the SSE-parse-error pattern) matches what 2.0 already does — no need to touch the 2.0 line, just backport-equivalent for 1.x.himanshu-commits commentedon Jul 13, 2026
thanks for the reply, i am heading for the solution, soon i will provide PR.
himanshu-commits commentedon Jul 13, 2026
Hi @afterrburn please could you review my PR.
fix(client): catch read failures on the non-2xx body
zsxh1990 commentedon Jul 14, 2026
/claim
Agent: zsxh1990 (pr-genius maintainer)
Background: Python async/httpx experience, built MCP-related tools.
Plan:
Timeline: today
feat(client): carry the originating HTTP status in ErrorData.data
himanshu-commits commentedon Jul 14, 2026
There are now three PRs pointing at this issue, so here's how they fit together.
The issue has two halves. The report is "a 401 never reaches the caller, and is
indistinguishable from a timeout" — but that's really two problems:
raise_for_status()inside the POST's background task,nothing resolves the pending request, and the caller waits out its read timeout.
-32603 "Server returned an error response". A terminal 401 and a transient 503 arebyte-identical, so the retry classification the report asks for still isn't writable.
Where each half stands:
main(2.0)v1.xerror.datafix(client): surface non-2xx HTTP responses to the waiting caller #3092 (v1.x): removes the bare
raise_for_status()and answers non-2xx with aJSONRPCErrorstamped with the original request's id, so it resolves through the normalcorrelation path. Behaviour matches 2.0, with 404 deliberately left byte-for-byte unchanged
(a terminated session 404s with a JSON-RPC body, and callers match on
code == 32600).Reviewed by @afterrburn; CI green.
feat(client): carry the originating HTTP status in ErrorData.data #3094 (main, draft): puts the originating status on
error.dataas{"httpStatus": 503}— additive,
datais free-form per the JSON-RPC spec. This is what makes "don't retry a 401,do retry a 503" possible, which is the impact @afterrburn originally filed about and has since
said his team needs. It's a draft only because this repo caps non-maintainers at one open PR;
the work is complete and CI is green. Open naming question:
httpStatus(matching MCP'scamelCase wire conventions) vs @afterrburn's suggested
http_status— happy either way.test: add tests for non-2xx HTTP status handling (fixes #3091) #3093 (main, @zsxh1990): adds regression tests for the non-2xx handling that
mainalreadyhas. Complementary rather than overlapping — it doesn't touch v1.x, where the hang still
reproduces, and doesn't address the status half.
One more thing found along the way, not covered by any of the above:
_handle_resumption_requeststill calls a bareevent_source.response.raise_for_status()onboth lines (
v1.x:320,main:257). Same failure mode as this issue — it runs inside therequest's task, so a 401 there strands the caller exactly as before. The other
raise_for_status()calls in the file are safe (wrapped, and no caller awaiting an id). Happy topick that up if it's wanted; say the word and I'll open a separate issue or PR.
maxisbey commentedon Aug 14, 2026
This is the same underlying problem tracked in #2110 (non-2xx responses on the Streamable HTTP client not reaching the waiting caller, and the HTTP status not being preserved on the resulting error), so I'm closing this one to keep the discussion in one place. As noted in the thread, the hang itself is fixed in v2, which is now released; preserving the originating status is the remaining piece, and #3094 can be linked against #2110 for that. The 1.x line is only taking critical fixes at this point. Feel free to reopen if there's something here that #2110 doesn't cover.
AI Disclaimer