Skip to content

Commit 8d17351

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

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

src/mcp/client/streamable_http.py

Lines changed: 11 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,16 @@ 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(code=INTERNAL_ERROR, message="Server returned an error response"),
365+
)
366+
await ctx.read_stream_writer.send(SessionMessage(JSONRPCMessage(jsonrpc_error)))
367+
return
368+
359369
if is_initialization:
360370
self._maybe_extract_session_id_from_response(response)
361371

tests/shared/test_streamable_http.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,157 @@ 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+
1088+
async def run_good_request(session: ClientSession) -> None:
1089+
nonlocal good_result
1090+
good_result = await session.send_request(
1091+
types.ClientRequest(
1092+
types.ReadResourceRequest(
1093+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://good")),
1094+
)
1095+
),
1096+
types.ReadResourceResult,
1097+
)
1098+
1099+
async def run_bad_request(session: ClientSession) -> None:
1100+
nonlocal bad_error
1101+
try:
1102+
await session.send_request(
1103+
types.ClientRequest(
1104+
types.ReadResourceRequest(
1105+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://bad")),
1106+
)
1107+
),
1108+
types.ReadResourceResult,
1109+
)
1110+
except Exception as exc:
1111+
bad_error = exc
1112+
1113+
transport = httpx.MockTransport(handler)
1114+
async with httpx.AsyncClient(transport=transport) as http_client:
1115+
async with streamable_http_client("http://test/mcp", http_client=http_client) as (
1116+
read_stream,
1117+
write_stream,
1118+
_,
1119+
):
1120+
async with ClientSession(read_stream, write_stream) as session:
1121+
async with anyio.create_task_group() as tg:
1122+
tg.start_soon(run_good_request, session)
1123+
with anyio.fail_after(5):
1124+
await good_request_started.wait()
1125+
1126+
tg.start_soon(run_bad_request, session)
1127+
1128+
with anyio.fail_after(5):
1129+
while bad_error is None:
1130+
await anyio.sleep(0)
1131+
1132+
allow_good_response.set()
1133+
1134+
assert isinstance(bad_error, McpError)
1135+
assert bad_error.error.code == types.INTERNAL_ERROR
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 (
1175+
read_stream,
1176+
write_stream,
1177+
_,
1178+
):
1179+
async with ClientSession(read_stream, write_stream) as session:
1180+
await session.send_notification(types.ClientNotification(types.RootsListChangedNotification()))
1181+
with anyio.fail_after(5):
1182+
await notification_seen.wait()
1183+
1184+
result = await session.send_request(
1185+
types.ClientRequest(
1186+
types.ReadResourceRequest(
1187+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://good")),
1188+
)
1189+
),
1190+
types.ReadResourceResult,
1191+
)
1192+
1193+
assert isinstance(result.contents[0], types.TextResourceContents)
1194+
assert result.contents[0].text == "good response"
1195+
1196+
10461197
@pytest.mark.anyio
10471198
async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str):
10481199
"""Test that session ID persists across requests."""

0 commit comments

Comments
 (0)