Skip to content

Commit ab917f9

Browse files
fix streamable http post error isolation
1 parent 77431eb commit ab917f9

2 files changed

Lines changed: 163 additions & 1 deletion

File tree

src/mcp/client/streamable_http.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from mcp.shared.message import ClientMessageMetadata, SessionMessage
3030
from mcp.types import (
31+
INTERNAL_ERROR,
3132
ErrorData,
3233
InitializeResult,
3334
JSONRPCError,
@@ -355,7 +356,20 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
355356
) # pragma: no cover
356357
return # pragma: no cover
357358

358-
response.raise_for_status()
359+
if response.status_code >= 400:
360+
if isinstance(message.root, JSONRPCRequest):
361+
jsonrpc_error = JSONRPCError(
362+
jsonrpc="2.0",
363+
id=message.root.id,
364+
error=ErrorData(
365+
code=INTERNAL_ERROR,
366+
message=f"Server returned HTTP {response.status_code}",
367+
data={"status_code": response.status_code},
368+
),
369+
)
370+
await ctx.read_stream_writer.send(SessionMessage(JSONRPCMessage(jsonrpc_error)))
371+
return
372+
359373
if is_initialization:
360374
self._maybe_extract_session_id_from_response(response)
361375

tests/shared/test_streamable_http.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,154 @@ async def test_streamable_http_client_error_handling(initialized_client_session:
10431043
assert "Unknown resource: unknown://test-error" in exc_info.value.error.message
10441044

10451045

1046+
@pytest.mark.anyio
1047+
async def test_streamable_http_client_http_error_does_not_cancel_concurrent_request():
1048+
"""Test that one POST HTTP error does not tear down an unrelated request."""
1049+
good_request_started = anyio.Event()
1050+
allow_good_response = anyio.Event()
1051+
1052+
async def handler(request: httpx.Request) -> httpx.Response:
1053+
payload = json.loads(request.content)
1054+
request_id = payload["id"]
1055+
uri = payload["params"]["uri"]
1056+
1057+
if uri == "foobar://bad":
1058+
with anyio.fail_after(5):
1059+
await good_request_started.wait()
1060+
return httpx.Response(400, request=request, json={"error": "boom"})
1061+
1062+
assert uri == "foobar://good"
1063+
good_request_started.set()
1064+
with anyio.fail_after(5):
1065+
await allow_good_response.wait()
1066+
return httpx.Response(
1067+
200,
1068+
request=request,
1069+
headers={"content-type": "application/json"},
1070+
json={
1071+
"jsonrpc": "2.0",
1072+
"id": request_id,
1073+
"result": {
1074+
"contents": [
1075+
{
1076+
"uri": uri,
1077+
"mimeType": "text/plain",
1078+
"text": "good response",
1079+
}
1080+
]
1081+
},
1082+
},
1083+
)
1084+
1085+
good_result: types.ReadResourceResult | None = None
1086+
bad_error: Exception | None = None
1087+
bad_request_failed = anyio.Event()
1088+
1089+
async def run_good_request(session: ClientSession) -> None:
1090+
nonlocal good_result
1091+
good_result = await session.send_request(
1092+
types.ClientRequest(
1093+
types.ReadResourceRequest(
1094+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://good")),
1095+
)
1096+
),
1097+
types.ReadResourceResult,
1098+
)
1099+
1100+
async def run_bad_request(session: ClientSession) -> None:
1101+
nonlocal bad_error
1102+
try:
1103+
await session.send_request(
1104+
types.ClientRequest(
1105+
types.ReadResourceRequest(
1106+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://bad")),
1107+
)
1108+
),
1109+
types.ReadResourceResult,
1110+
)
1111+
except Exception as exc:
1112+
bad_error = exc
1113+
bad_request_failed.set()
1114+
1115+
transport = httpx.MockTransport(handler)
1116+
async with httpx.AsyncClient(transport=transport) as http_client:
1117+
async with streamable_http_client("http://test/mcp", http_client=http_client) as streams: # pragma: no branch
1118+
read_stream, write_stream, _ = streams
1119+
async with ClientSession(read_stream, write_stream) as session:
1120+
async with anyio.create_task_group() as tg:
1121+
tg.start_soon(run_good_request, session)
1122+
with anyio.fail_after(5):
1123+
await good_request_started.wait()
1124+
1125+
tg.start_soon(run_bad_request, session)
1126+
1127+
with anyio.fail_after(5):
1128+
await bad_request_failed.wait()
1129+
1130+
allow_good_response.set()
1131+
1132+
assert isinstance(bad_error, McpError)
1133+
assert bad_error.error.code == types.INTERNAL_ERROR
1134+
assert bad_error.error.message == "Server returned HTTP 400"
1135+
assert bad_error.error.data == {"status_code": 400}
1136+
assert good_result is not None
1137+
assert isinstance(good_result.contents[0], types.TextResourceContents)
1138+
assert good_result.contents[0].text == "good response"
1139+
1140+
1141+
@pytest.mark.anyio
1142+
async def test_streamable_http_client_notification_http_error_does_not_cancel_transport():
1143+
"""Test POST HTTP errors for notifications do not synthesize responses."""
1144+
notification_seen = anyio.Event()
1145+
1146+
async def handler(request: httpx.Request) -> httpx.Response:
1147+
payload = json.loads(request.content)
1148+
1149+
if "id" not in payload:
1150+
notification_seen.set()
1151+
return httpx.Response(500, request=request, json={"error": "boom"})
1152+
1153+
return httpx.Response(
1154+
200,
1155+
request=request,
1156+
headers={"content-type": "application/json"},
1157+
json={
1158+
"jsonrpc": "2.0",
1159+
"id": payload["id"],
1160+
"result": {
1161+
"contents": [
1162+
{
1163+
"uri": "foobar://good",
1164+
"mimeType": "text/plain",
1165+
"text": "good response",
1166+
}
1167+
]
1168+
},
1169+
},
1170+
)
1171+
1172+
transport = httpx.MockTransport(handler)
1173+
async with httpx.AsyncClient(transport=transport) as http_client:
1174+
async with streamable_http_client("http://test/mcp", http_client=http_client) as streams: # pragma: no branch
1175+
read_stream, write_stream, _ = streams
1176+
async with ClientSession(read_stream, write_stream) as session:
1177+
await session.send_notification(types.ClientNotification(types.RootsListChangedNotification()))
1178+
with anyio.fail_after(5):
1179+
await notification_seen.wait()
1180+
1181+
result = await session.send_request(
1182+
types.ClientRequest(
1183+
types.ReadResourceRequest(
1184+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://good")),
1185+
)
1186+
),
1187+
types.ReadResourceResult,
1188+
)
1189+
1190+
assert isinstance(result.contents[0], types.TextResourceContents)
1191+
assert result.contents[0].text == "good response"
1192+
1193+
10461194
@pytest.mark.anyio
10471195
async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str):
10481196
"""Test that session ID persists across requests."""

0 commit comments

Comments
 (0)