Skip to content

Commit 64907f9

Browse files
committed
fix(client): treat any 2xx as successful session termination
Streamable HTTP DELETE termination warned 'Session termination failed: 202' for spec-compliant asynchronous deletes. HTTP semantics make any 2xx a success; the Streamable HTTP spec only carves out 405 for this request. Accept the whole 2xx range and keep the warning for genuine failures. Fixes #3546
1 parent 6affe5c commit 64907f9

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

src/mcp/client/streamable_http.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,9 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None:
671671

672672
if response.status_code == 405:
673673
logger.debug("Server does not allow session termination")
674-
elif response.status_code not in (200, 204):
674+
elif not 200 <= response.status_code < 300:
675+
# Any 2xx is a successful termination; servers may answer an
676+
# asynchronous delete with 202 Accepted (spec only carves out 405).
675677
logger.warning(f"Session termination failed: {response.status_code}") # pragma: no cover
676678
except Exception as exc: # pragma: no cover
677679
logger.warning(f"Session termination failed: {exc}")

tests/shared/test_streamable_http.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,56 @@ async def __aexit__(self, *args: Any) -> None:
12071207
assert exc_info.value.error.message == "Session not found"
12081208

12091209

1210+
@pytest.mark.anyio
1211+
@pytest.mark.parametrize("delete_status,expect_warning", [(202, False), (500, True)])
1212+
async def test_streamable_http_client_session_termination_status_handling(
1213+
basic_app: Starlette, caplog: pytest.LogCaptureFixture, delete_status: int, expect_warning: bool
1214+
) -> None:
1215+
"""Any 2xx DELETE response is a successful termination; only other statuses warn.
1216+
1217+
202 Accepted is a valid answer for a server that processes termination asynchronously;
1218+
a warning there is a false positive (see #3546). 500 is a real failure and still warns.
1219+
"""
1220+
1221+
class AnswerDeleteWithStatus(httpx2.AsyncBaseTransport):
1222+
def __init__(self, inner: StreamingASGITransport, status: int) -> None:
1223+
self.inner = inner
1224+
self.status = status
1225+
1226+
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
1227+
response = await self.inner.handle_async_request(request)
1228+
if request.method != "DELETE" or response.status_code != 200:
1229+
return response
1230+
await response.aread()
1231+
return httpx2.Response(self.status, headers=response.headers, request=request)
1232+
1233+
async def __aenter__(self) -> AnswerDeleteWithStatus:
1234+
await self.inner.__aenter__()
1235+
return self
1236+
1237+
async def __aexit__(self, *args: Any) -> None:
1238+
await self.inner.__aexit__(*args)
1239+
1240+
httpx_client, _ = create_session_id_capturing_client(
1241+
basic_app, transport=AnswerDeleteWithStatus(StreamingASGITransport(basic_app), delete_status)
1242+
)
1243+
1244+
with caplog.at_level(logging.WARNING, logger="mcp.client.streamable_http"):
1245+
async with httpx_client:
1246+
async with streamable_http_client(f"{BASE_URL}/mcp", http_client=httpx_client) as (
1247+
read_stream,
1248+
write_stream,
1249+
):
1250+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
1251+
await session.initialize()
1252+
await session.list_tools()
1253+
1254+
warning_logged = "Session termination failed" in caplog.text
1255+
assert warning_logged is expect_warning, (
1256+
f"delete_status={delete_status}: warning_logged={warning_logged}, expected={expect_warning}"
1257+
)
1258+
1259+
12101260
@pytest.mark.anyio
12111261
async def test_streamable_http_client_resumption(event_app: tuple[SimpleEventStore, Starlette]) -> None:
12121262
"""A second client resumes an interrupted request with a resumption token and receives the rest."""

0 commit comments

Comments
 (0)