Skip to content

Commit d9e20f2

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

2 files changed

Lines changed: 111 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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,106 @@ 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+
good_error: Exception | None = None
1087+
bad_error: Exception | None = None
1088+
1089+
async def run_good_request(session: ClientSession) -> None:
1090+
nonlocal good_result, good_error
1091+
try:
1092+
good_result = await session.send_request(
1093+
types.ClientRequest(
1094+
types.ReadResourceRequest(
1095+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://good")),
1096+
)
1097+
),
1098+
types.ReadResourceResult,
1099+
)
1100+
except Exception as exc:
1101+
good_error = exc
1102+
1103+
async def run_bad_request(session: ClientSession) -> None:
1104+
nonlocal bad_error
1105+
try:
1106+
await session.send_request(
1107+
types.ClientRequest(
1108+
types.ReadResourceRequest(
1109+
params=types.ReadResourceRequestParams(uri=AnyUrl("foobar://bad")),
1110+
)
1111+
),
1112+
types.ReadResourceResult,
1113+
)
1114+
except Exception as exc:
1115+
bad_error = exc
1116+
1117+
transport = httpx.MockTransport(handler)
1118+
async with httpx.AsyncClient(transport=transport) as http_client:
1119+
async with streamable_http_client("http://test/mcp", http_client=http_client) as (
1120+
read_stream,
1121+
write_stream,
1122+
_,
1123+
):
1124+
async with ClientSession(read_stream, write_stream) as session:
1125+
async with anyio.create_task_group() as tg:
1126+
tg.start_soon(run_good_request, session)
1127+
with anyio.fail_after(5):
1128+
await good_request_started.wait()
1129+
1130+
tg.start_soon(run_bad_request, session)
1131+
1132+
with anyio.fail_after(5):
1133+
while bad_error is None:
1134+
await anyio.sleep(0)
1135+
1136+
allow_good_response.set()
1137+
1138+
assert isinstance(bad_error, McpError)
1139+
assert bad_error.error.code == types.INTERNAL_ERROR
1140+
assert good_error is None
1141+
assert good_result is not None
1142+
assert isinstance(good_result.contents[0], types.TextResourceContents)
1143+
assert good_result.contents[0].text == "good response"
1144+
1145+
10461146
@pytest.mark.anyio
10471147
async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str):
10481148
"""Test that session ID persists across requests."""

0 commit comments

Comments
 (0)