Skip to content

Commit f218dc8

Browse files
committed
fix: isolate streamable http request errors
1 parent 161834d commit f218dc8

2 files changed

Lines changed: 70 additions & 4 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,19 @@ async def _handle_message(session_message: SessionMessage) -> None:
468468
)
469469

470470
async def handle_request_async():
471-
if is_resumption:
472-
await self._handle_resumption_request(ctx)
473-
else:
474-
await self._handle_post_request(ctx)
471+
try:
472+
if is_resumption:
473+
await self._handle_resumption_request(ctx)
474+
else:
475+
await self._handle_post_request(ctx)
476+
except Exception as exc:
477+
if not isinstance(message, JSONRPCRequest):
478+
raise
479+
480+
logger.exception("Error handling streamable HTTP request")
481+
error_data = ErrorData(code=INTERNAL_ERROR, message=f"Request failed: {exc}")
482+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
483+
await ctx.read_stream_writer.send(error_msg)
475484

476485
# If this is a request, start a new task to handle it
477486
if isinstance(message, JSONRPCRequest):

tests/shared/test_streamable_http.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@
5757
CallToolRequestParams,
5858
CallToolResult,
5959
InitializeResult,
60+
JSONRPCError,
61+
JSONRPCNotification,
6062
JSONRPCRequest,
63+
JSONRPCResponse,
6164
ListToolsResult,
6265
PaginatedRequestParams,
6366
ReadResourceRequestParams,
@@ -1105,6 +1108,60 @@ async def test_streamable_http_client_error_handling(initialized_client_session:
11051108
assert "Unknown resource: unknown://test-error" in exc_info.value.error.message
11061109

11071110

1111+
@pytest.mark.anyio
1112+
async def test_streamable_http_request_error_does_not_close_writer():
1113+
async def handler(request: httpx.Request) -> httpx.Response:
1114+
body = json.loads(request.content)
1115+
if body["method"] == "tools/list":
1116+
raise httpx.ConnectError("boom", request=request)
1117+
1118+
return httpx.Response(
1119+
200,
1120+
headers={"content-type": "application/json"},
1121+
json={"jsonrpc": "2.0", "id": body["id"], "result": {}},
1122+
request=request,
1123+
)
1124+
1125+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
1126+
async with streamable_http_client("http://testserver/mcp", http_client=client) as (read_stream, write_stream):
1127+
await write_stream.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="bad", method="tools/list")))
1128+
1129+
with anyio.fail_after(1):
1130+
error_message = await read_stream.receive()
1131+
1132+
assert isinstance(error_message, SessionMessage)
1133+
assert isinstance(error_message.message, JSONRPCError)
1134+
assert error_message.message.id == "bad"
1135+
assert error_message.message.error.code == types.INTERNAL_ERROR
1136+
1137+
await write_stream.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="ok", method="ping")))
1138+
1139+
with anyio.fail_after(1):
1140+
response_message = await read_stream.receive()
1141+
1142+
assert isinstance(response_message, SessionMessage)
1143+
assert isinstance(response_message.message, JSONRPCResponse)
1144+
assert response_message.message.id == "ok"
1145+
1146+
1147+
@pytest.mark.anyio
1148+
async def test_streamable_http_notification_error_still_closes_writer():
1149+
request_seen = anyio.Event()
1150+
1151+
async def handler(request: httpx.Request) -> httpx.Response:
1152+
request_seen.set()
1153+
raise httpx.ConnectError("boom", request=request)
1154+
1155+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
1156+
async with streamable_http_client("http://testserver/mcp", http_client=client) as (_, write_stream):
1157+
await write_stream.send(
1158+
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"))
1159+
)
1160+
1161+
with anyio.fail_after(1):
1162+
await request_seen.wait()
1163+
1164+
11081165
@pytest.mark.anyio
11091166
async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str):
11101167
"""Test that session ID persists across requests."""

0 commit comments

Comments
 (0)