Skip to content

Streamable HTTP client drops the HTTP status of an error response #3857

Open

Description

@pedroallenrevez

What happens

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.

Versions

mcp 2.3.0 (checked against main as well).

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Nov 13, 2026
  2. Shivansh12t commented on Nov 13, 2026

    @Shivansh12t

    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.

  3. Abdulrehman1978 commented on Nov 14, 2026

    @Abdulrehman1978

    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.

    I have a tested fix ready on ix/streamable-http-error-status that:

    1. 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}.
    2. Adds parametrized unit tests in tests/client/test_streamable_http.py covering standard and non-standard HTTP error statuses.
    3. Updates corresponding troubleshooting doc references and tests.

    Please assign this issue to me so I can open the pull request per the contributing guidelines.

  4. media-ctrl commented on Nov 14, 2026

    @media-ctrl

    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.

    1. 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.

    2. 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.

    3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions