You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Streamable HTTP client drops the HTTP status of an error response #3857
When the server (or a proxy or gateway in front of it) answers a JSON-RPC request with a non-2xx status and no JSON-RPC error body, the streamable HTTP client replaces the response with a generic error:
# mcp/client/streamable_http.py (main and 2.3.0)error_data=ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
The status code, reason phrase and URL are dropped, so a caller cannot tell a 401 from a 502 or a 504. Every non-404 HTTP failure reaches the caller as the same -32603 error.
Why it matters
Callers cannot decide whether a failure is worth retrying (a gateway 502/503/504) or not (a 401, 403 or 413).
Logs and user-facing errors cannot name the cause. We saw this with an install whose MCP URL went through an auth proxy: every call failed with a 401, and all we could log was "Server returned an error response".
Suggestion
Keep the HTTP status in the error, for example ErrorData(code=INTERNAL_ERROR, message=f"Server returned an error response ({status} {reason})", data={"http_status": status}), so callers can branch on it without a response hook of their own.
Hey, I came across this while reading streamable_http.py, and I have a small patch ready that I've tested locally. I used AI assistance (Claude Code) to write and test it.
Proposed approach: keep the existing stand-in error and add the status to its data field, so the change is additive:
ErrorData(
code=INTERNAL_ERROR,
message="Server returned an error response",
data={"http_status": response.status_code},
)
Callers can then branch on error.data["http_status"] to treat 502/503/504 as retryable and 401/403/413 as terminal. I left the message text unchanged on purpose. docs/troubleshooting.md, docs/migration.md and a couple of existing tests quote it, and the 2.x line treats observable-behavior changes as a maintainer decision.
Question: would you prefer the status in the message too, for example "Server returned an error response (502)", so it shows up in logs without reading data? Or is data alone fine?
The patch is about 7 lines in streamable_http.py, plus a parametrized test (401, 403, 413, 500, 502, 504) that fails without the change. The full suite, ruff and pyright pass locally.
Request: if you're happy with this direction, could you please assign this issue to me? I'll then open the PR right away, linked with Fixes #3857, and adjust it to whichever option you prefer.
I reproduced this issue on main when a proxy returns a non-2xx response without a JSON-RPC body. When a 401, 502, or 504 is returned, callers receive a generic -32603 Server returned an error response, with the HTTP status and reason phrase lost, making status-based retry or auth branching impossible without an out-of-band response hook.
Updates StreamableHTTPTransport._handle_post_request fallback to retain the status and reason phrase in ErrorData.message (Server returned an error response ({status_code} {reason_phrase})) and populate ErrorData.data with {"http_status": response.status_code}.
Adds parametrized unit tests in tests/client/test_streamable_http.py covering standard and non-standard HTTP error statuses.
Updates corresponding troubleshooting doc references and tests.
Please assign this issue to me so I can open the pull request per the contributing guidelines.
Both proposed patches are on the right track, and the data payload is schema-legal: ErrorData.data is Any = None, so nothing needs to change in the error model. Three things the patches do not cover yet, all visible at the same line that builds the stand-in.
Retryable is only half a decision. A 429 and a 503 both carry Retry-After, and both headers are in hand at line 426 where the response object still exists. Capturing the status alone leaves every caller to invent its own backoff, which is the step where retry logic usually goes wrong. Suggest data={"http_status": ..., "retry_after": response.headers.get("retry-after")} in the same edit, since the marginal cost is one dict entry.
The 404 branch already encodes state, not status. At line 417 the code maps 404 to METHOD_NOT_FOUND when session_id is None and to INVALID_REQUEST "Session terminated" when a session exists. A generic http_status field must not be allowed to flatten that: a caller branching on status needs to know that 404 is derived from session state and that the same status means two different things. The mapping table worth writing down is 401 and 403 post-refresh, 404 state-derived, 413 terminal, 429 502 503 504 retryable with Retry-After.
A 401 that reaches this fallback is not "needs discovery". The auth layer already consumes the challenge: the OAuth provider branches on 401 and re-runs discovery and token steps, and the WWW-Authenticate scope parser in the auth utils reads the header. So by the time a 401 lands here the provider has already tried, and the useful distinction for a caller is a rejected credential, not a missing one. Worth saying in the docs, otherwise the obvious reaction is to re-run discovery in a loop.
On the message versus data question: both, but they are different audiences. The message is what a naive str(error) puts in a log, the data is what code branches on. Since the current message text is quoted in the troubleshooting docs and in tests, adding the status to data first and treating the message change as a separate decision keeps the patch reviewable and loses nothing.
We run MCP servers in front of a number of business APIs and log every transport failure, and the difference between a gateway 502 and a token 401 is the difference between "retry" and "someone needs to look at this". Today both arrive as the same -32603, so the log line carries no decision.
Activity
Shivansh12t commentedon Nov 13, 2026
Hey, I came across this while reading
streamable_http.py, and I have a small patch ready that I've tested locally. I used AI assistance (Claude Code) to write and test it.Proposed approach: keep the existing stand-in error and add the status to its
datafield, so the change is additive:Callers can then branch on
error.data["http_status"]to treat 502/503/504 as retryable and 401/403/413 as terminal. I left the message text unchanged on purpose.docs/troubleshooting.md,docs/migration.mdand a couple of existing tests quote it, and the 2.x line treats observable-behavior changes as a maintainer decision.Question: would you prefer the status in the message too, for example
"Server returned an error response (502)", so it shows up in logs without readingdata? Or isdataalone fine?The patch is about 7 lines in
streamable_http.py, plus a parametrized test (401, 403, 413, 500, 502, 504) that fails without the change. The full suite, ruff and pyright pass locally.Request: if you're happy with this direction, could you please assign this issue to me? I'll then open the PR right away, linked with
Fixes #3857, and adjust it to whichever option you prefer.Abdulrehman1978 commentedon Nov 14, 2026
I reproduced this issue on
mainwhen a proxy returns a non-2xx response without a JSON-RPC body. When a 401, 502, or 504 is returned, callers receive a generic-32603 Server returned an error response, with the HTTP status and reason phrase lost, making status-based retry or auth branching impossible without an out-of-band response hook.I have a tested fix ready on ix/streamable-http-error-status that:
StreamableHTTPTransport._handle_post_requestfallback to retain the status and reason phrase inErrorData.message(Server returned an error response ({status_code} {reason_phrase})) and populateErrorData.datawith{"http_status": response.status_code}.tests/client/test_streamable_http.pycovering standard and non-standard HTTP error statuses.Please assign this issue to me so I can open the pull request per the contributing guidelines.
media-ctrl commentedon Nov 14, 2026
Both proposed patches are on the right track, and the
datapayload is schema-legal:ErrorData.dataisAny = None, so nothing needs to change in the error model. Three things the patches do not cover yet, all visible at the same line that builds the stand-in.Retryable is only half a decision. A 429 and a 503 both carry
Retry-After, and both headers are in hand at line 426 where the response object still exists. Capturing the status alone leaves every caller to invent its own backoff, which is the step where retry logic usually goes wrong. Suggestdata={"http_status": ..., "retry_after": response.headers.get("retry-after")}in the same edit, since the marginal cost is one dict entry.The 404 branch already encodes state, not status. At line 417 the code maps 404 to METHOD_NOT_FOUND when
session_idis None and to INVALID_REQUEST "Session terminated" when a session exists. A generichttp_statusfield must not be allowed to flatten that: a caller branching on status needs to know that 404 is derived from session state and that the same status means two different things. The mapping table worth writing down is 401 and 403 post-refresh, 404 state-derived, 413 terminal, 429 502 503 504 retryable with Retry-After.A 401 that reaches this fallback is not "needs discovery". The auth layer already consumes the challenge: the OAuth provider branches on 401 and re-runs discovery and token steps, and the WWW-Authenticate scope parser in the auth utils reads the header. So by the time a 401 lands here the provider has already tried, and the useful distinction for a caller is a rejected credential, not a missing one. Worth saying in the docs, otherwise the obvious reaction is to re-run discovery in a loop.
On the message versus data question: both, but they are different audiences. The message is what a naive
str(error)puts in a log, the data is what code branches on. Since the current message text is quoted in the troubleshooting docs and in tests, adding the status todatafirst and treating the message change as a separate decision keeps the patch reviewable and loses nothing.We run MCP servers in front of a number of business APIs and log every transport failure, and the difference between a gateway 502 and a token 401 is the difference between "retry" and "someone needs to look at this". Today both arrive as the same -32603, so the log line carries no decision.