fix: propagate HTTP errors from transports instead of silently logging#2249
fix: propagate HTTP errors from transports instead of silently logging#2249MaxwellCalkin wants to merge 4 commits intomodelcontextprotocol:mainfrom
Conversation
Bortlesboat
left a comment
There was a problem hiding this comment.
Good fix for a real problem — I run into hanging clients on auth failures regularly. Two issues I noticed with the error propagation though.
| if response.status_code in (401, 403): | ||
| status_label = "Unauthorized" if response.status_code == 401 else "Forbidden" | ||
| error_message = f"HTTP {response.status_code} {status_label}" | ||
| if isinstance(message, JSONRPCRequest): |
There was a problem hiding this comment.
This raises after already sending a JSONRPCError to the read stream (line 26). But handle_request_async wraps _handle_post_request in a try/except that catches this HttpError and sends it to read_stream_writer again. So for JSONRPCRequest messages hitting 401/403 (and same for the generic >=400 path below), the consumer receives both a SessionMessage(JSONRPCError) and then an HttpError exception from the same stream — two messages for one request.
I think you either want to not raise here when the JSONRPCError was already sent, or skip the JSONRPCError and just raise (letting the wrapper forward it). The current approach of doing both will confuse callers.
| status_label = "Unauthorized" if response.status_code == 401 else "Forbidden" | ||
| exc = HttpError( | ||
| response.status_code, | ||
| f"HTTP {response.status_code} {status_label}", |
There was a problem hiding this comment.
Re-raising HttpError here means it escapes post_writer entirely — nothing above catches it, so it propagates into the task group and crashes the whole SSE client connection. The original code intentionally swallowed all exceptions to keep the writer alive for the connection lifetime.
For the SSE transport I think you want the same pattern as the other exceptions: send it through the read stream but don't re-raise. The caller can handle it from the stream without killing the connection.
Note: This PR was authored by Claude (AI), operated by @MaxwellCalkin.
Summary
Fixes #2110
When an MCP server returns non-2xx HTTP status codes (401/403/404/5xx), the Streamable HTTP and SSE transports silently log the error but don't propagate it to the caller. The caller hangs indefinitely waiting for a response on the read stream.
Changes
New
HttpErrorexception class (src/mcp/shared/exceptions.py):is_auth_errorproperty to distinguish 401/403 from other failuresbodyfield for response body contextmcppackageStreamable HTTP transport (
src/mcp/client/streamable_http.py):_handle_post_request: 401/403 responses now raiseHttpError(in addition to sending a JSONRPCError for request messages), so the caller gets an exception instead of hanging_handle_post_request: Generic 4xx/5xx responses also raiseHttpErrorinstead of silently returning_handle_post_request: HTTP 404 error message now includes "(HTTP 404)" anddata={"http_status": 404}to preserve HTTP-level context_handle_post_request: All error responses includedata={"http_status": <code>}in the JSONRPCErrorpost_writer:handle_request_asyncnow wraps calls in try/except and forwards exceptions throughread_stream_writer.send(exc)so callers don't hangpost_writer: Outer exception handler also forwards errors through the read streamSSE transport (
src/mcp/client/sse.py):post_writer: 401/403 responses detected beforeraise_for_status()and forwarded asHttpErrorthrough the read streampost_writer:httpx.HTTPStatusErrorfromraise_for_status()converted toHttpErrorand forwarded through the read streampost_writer: Generic exceptions forwarded through the read stream instead of being silently loggedBefore
After
Test plan
HttpErrorwithis_auth_error == TrueHttpErrorand don't cause caller to hangHttpErroris importable frommcptop-level package