Skip to content

Commit bbbd40e

Browse files
committed
fix: handle HTTP error status codes in streamable HTTP client
Replace `response.raise_for_status()` with explicit status code handling that sends a JSONRPCError back through the read stream when the server returns a 4xx/5xx response. This prevents the client from raising an unhandled httpx.HTTPStatusError that would cause pending requests to hang indefinitely. This is a follow-up to #2005 which fixed similar issues for unexpected content types, JSON parse failures, and SSE parse failures but did not address plain HTTP error responses.
1 parent 239d682 commit bbbd40e

File tree

2 files changed

+42
-1
lines changed

2 files changed

+42
-1
lines changed

src/mcp/client/streamable_http.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mcp.shared._httpx_utils import create_mcp_http_client
2020
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2121
from mcp.types import (
22+
INTERNAL_ERROR,
2223
INVALID_REQUEST,
2324
PARSE_ERROR,
2425
ErrorData,
@@ -273,7 +274,13 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
273274
await ctx.read_stream_writer.send(session_message)
274275
return
275276

276-
response.raise_for_status()
277+
if response.status_code >= 400:
278+
if isinstance(message, JSONRPCRequest):
279+
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
280+
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
281+
await ctx.read_stream_writer.send(session_message)
282+
return
283+
277284
if is_initialization:
278285
self._maybe_extract_session_id_from_response(response)
279286

tests/client/test_notification_response.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,40 @@ async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
116116
await session.list_tools()
117117

118118

119+
def _create_http_error_app(error_status: int) -> Starlette:
120+
"""Create a server that returns an HTTP error for non-init requests."""
121+
122+
async def handle_mcp_request(request: Request) -> Response:
123+
body = await request.body()
124+
data = json.loads(body)
125+
126+
if data.get("method") == "initialize":
127+
return _init_json_response(data)
128+
129+
if "id" not in data:
130+
return Response(status_code=202)
131+
132+
return Response(status_code=error_status)
133+
134+
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
135+
136+
137+
async def test_http_error_status_sends_jsonrpc_error() -> None:
138+
"""Verify HTTP 5xx errors unblock the pending request with an MCPError.
139+
140+
When a server returns a non-2xx status code (e.g. 500), the client should
141+
send a JSONRPCError so the pending request resolves immediately instead of
142+
raising an unhandled httpx.HTTPStatusError that causes the caller to hang.
143+
"""
144+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client:
145+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
146+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
147+
await session.initialize()
148+
149+
with pytest.raises(MCPError, match="Server returned an error response"): # pragma: no branch
150+
await session.list_tools()
151+
152+
119153
def _create_invalid_json_response_app() -> Starlette:
120154
"""Create a server that returns invalid JSON for requests."""
121155

0 commit comments

Comments
 (0)