Skip to content

Commit b4edbd4

Browse files
committed
fix(client): harden the resumption GET and SSE POST error paths per review
Address the review findings on the previous revision: - Dispatch resumption on message type as well as metadata: a notification stamped with a resumption token is POSTed as usual instead of tripping the resumption path's request-only assertion and killing the write loop. - Treat any non-2xx as a failure (response.is_success), restoring the raise_for_status() semantics the checks replaced: an unfollowed redirect resolves the caller instead of being logged as success. - Map a 404 on the resumption GET while a session id is held to INVALID_REQUEST / "Session terminated", the POST path's session-expiry signal, so reconnect logic keyed on it works across both. - Contain the resumption read loop like _handle_sse_response: a stream dying mid-read or ending cleanly without a response resolves the waiter (CONNECTION_CLOSED) instead of tearing down the transport or hanging. - Resolve the resumption GET's status errors via _resolve_abandoned_request for its closed-stream containment instead of hand-building the error. - Surface network-level errors (httpx.HTTPError) on the SSE message POST through the same correlated path: on this transport nothing escapes loudly, so the caller previously hung forever. - Deduplicate the SSE test app wiring behind make_app(wrap_post=...). Seven new regression tests pin the above; each fails against the previous revision (hang into fail_after, transport teardown, or wrong error). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AuJi8kEB3bhikW2pzbmhUL
1 parent e5fe739 commit b4edbd4

4 files changed

Lines changed: 304 additions & 76 deletions

File tree

src/mcp/client/sse.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -120,31 +120,39 @@ async def post_writer(endpoint_url: str):
120120
async with write_stream_reader, write_stream:
121121

122122
async def _send_message(session_message: SessionMessage) -> None:
123+
# A POST failure must not raise: the post_writer handler below
124+
# would swallow it, hanging the waiting caller forever and killing
125+
# the write loop (#2110). Mirror the streamable-HTTP transport
126+
# instead: resolve the waiter with an error correlated to its
127+
# request id, keeping the session usable.
123128
logger.debug(f"Sending client message: {session_message}")
124129
message = session_message.message
125-
response = await client.post(
126-
endpoint_url,
127-
json=message.model_dump(
128-
by_alias=True,
129-
mode="json",
130-
exclude_unset=True,
131-
),
132-
)
133-
if response.status_code >= 400:
134-
# Resolve the waiting caller with an error correlated to its
135-
# request id, mirroring the streamable-HTTP transport: raising
136-
# here would be swallowed by the post_writer handler below and
137-
# the caller would hang forever (#2110). A notification has no
138-
# waiter to resolve, so the failure is only logged.
130+
try:
131+
response = await client.post(
132+
endpoint_url,
133+
json=message.model_dump(
134+
by_alias=True,
135+
mode="json",
136+
exclude_unset=True,
137+
),
138+
)
139+
except httpx2.HTTPError as exc:
140+
logger.exception("Error POSTing message")
141+
error = types.ErrorData(
142+
code=types.CONNECTION_CLOSED, message=f"Failed to send message: {exc}"
143+
)
144+
else:
145+
if response.is_success:
146+
logger.debug(f"Client message sent successfully: {response.status_code}")
147+
return
139148
logger.error(f"Message POST returned HTTP status {response.status_code}")
140-
if isinstance(message, types.JSONRPCRequest):
141-
error_data = types.ErrorData(
142-
code=types.INTERNAL_ERROR, message="Server returned an error response"
143-
)
144-
reply = types.JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)
145-
await read_stream_writer.send(SessionMessage(reply))
146-
return
147-
logger.debug(f"Client message sent successfully: {response.status_code}")
149+
error = types.ErrorData(
150+
code=types.INTERNAL_ERROR, message="Server returned an error response"
151+
)
152+
# A notification has no waiter to resolve, so its failure is only logged.
153+
if isinstance(message, types.JSONRPCRequest):
154+
reply = types.JSONRPCError(jsonrpc="2.0", id=message.id, error=error)
155+
await read_stream_writer.send(SessionMessage(reply))
148156

149157
async for session_message in write_stream_reader:
150158
sender_ctx = write_stream_reader.last_context

src/mcp/client/streamable_http.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -248,32 +248,51 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
248248
else:
249249
raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover
250250

251-
# Only requests resume: a resumption token is only ever attached by a
252-
# request's metadata, so the original id is always available to map responses.
251+
# Only requests resume: post_writer dispatches here on message type as well as
252+
# metadata, so the original id is always available to map responses.
253253
assert isinstance(ctx.session_message.message, JSONRPCRequest)
254254
original_request_id = ctx.session_message.message.id
255255

256-
async with ctx.client.sse(self.url, headers=headers) as event_source:
257-
if event_source.response.status_code >= 400:
258-
# Resolve the waiting caller with an error correlated to its request,
259-
# mirroring `_handle_post_request`: an escaping `HTTPStatusError` would
260-
# tear down the transport's task group and every stream with it (#2110).
261-
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
262-
reply = JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data)
263-
await ctx.read_stream_writer.send(SessionMessage(reply))
264-
return
265-
logger.debug("Resumption GET SSE connection established")
256+
try:
257+
async with ctx.client.sse(self.url, headers=headers) as event_source:
258+
if not event_source.response.is_success:
259+
# Resolve the waiting caller with an error correlated to its request,
260+
# mirroring `_handle_post_request`: an escaping `HTTPStatusError` would
261+
# tear down the transport's task group and every stream with it (#2110).
262+
if event_source.response.status_code == 404 and self.session_id is not None:
263+
# The GET carried our Mcp-Session-Id, so a 404 is the session-expiry
264+
# signal reconnect logic keys on - same mapping as the POST path.
265+
await self._resolve_abandoned_request(
266+
ctx.read_stream_writer, original_request_id, "Session terminated", code=INVALID_REQUEST
267+
)
268+
return
269+
await self._resolve_abandoned_request(
270+
ctx.read_stream_writer,
271+
original_request_id,
272+
"Server returned an error response",
273+
code=INTERNAL_ERROR,
274+
)
275+
return
276+
logger.debug("Resumption GET SSE connection established")
266277

267-
async for sse in event_source: # pragma: no branch
268-
is_complete = await self._handle_sse_event(
269-
sse,
270-
ctx.read_stream_writer,
271-
original_request_id,
272-
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
273-
)
274-
if is_complete:
275-
await event_source.response.aclose()
276-
break
278+
async for sse in event_source:
279+
is_complete = await self._handle_sse_event(
280+
sse,
281+
ctx.read_stream_writer,
282+
original_request_id,
283+
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
284+
)
285+
if is_complete:
286+
await event_source.response.aclose()
287+
return
288+
except Exception:
289+
logger.debug("Resumption stream ended", exc_info=True)
290+
291+
# Stream ended without a response, cleanly or mid-read: resolve the waiter,
292+
# mirroring `_handle_sse_response`, else the caller would hang forever.
293+
await self._resolve_abandoned_request(
294+
ctx.read_stream_writer, original_request_id, "resumption stream ended without a response"
295+
)
277296

278297
def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool:
279298
"""Translate an outbound `notifications/cancelled` at 2026; True means "do not POST".
@@ -563,8 +582,10 @@ async def _handle_message(session_message: SessionMessage) -> None:
563582
else None
564583
)
565584

566-
# Check if this is a resumption request
567-
is_resumption = bool(metadata and metadata.resumption_token)
585+
# Only a request resumes: the token names an interrupted request's
586+
# stream, and `_handle_resumption_request` needs the id to correlate
587+
# its outcome. A notification stamped with one is POSTed as usual.
588+
is_resumption = bool(metadata and metadata.resumption_token) and isinstance(message, JSONRPCRequest)
568589

569590
logger.debug(f"Sending client message: {message}")
570591

tests/client/test_streamable_http.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from mcp.client.streamable_http import (
3535
LAST_EVENT_ID,
3636
MAX_RECONNECTION_ATTEMPTS,
37+
MCP_SESSION_ID,
3738
RequestContext,
3839
StreamableHTTPTransport,
3940
streamable_http_client,
@@ -135,11 +136,12 @@ def handler(request: httpx2.Request) -> httpx2.Response:
135136

136137

137138
@pytest.mark.anyio
138-
@pytest.mark.parametrize("status", [401, 403, 500])
139+
@pytest.mark.parametrize("status", [302, 401, 403, 500])
139140
async def test_resumption_get_http_error_resolves_caller_and_transport_survives(status: int) -> None:
140141
"""A non-2xx on the resumption GET resolves the waiting request with a JSON-RPC error
141142
correlated to its id, and the transport stays usable for follow-up requests (SDK-defined;
142143
#2110 — the status error used to escape into the task group and tear down every stream).
144+
An unfollowed redirect counts: its body is no event stream, so no response can arrive.
143145
"""
144146

145147
def handler(request: httpx2.Request) -> httpx2.Response:
@@ -174,6 +176,79 @@ def handler(request: httpx2.Request) -> httpx2.Response:
174176
assert follow_up.message.id == 2
175177

176178

179+
@pytest.mark.anyio
180+
async def test_resumption_get_404_with_session_reports_session_terminated() -> None:
181+
"""A 404 on the resumption GET while a session id is held reports "Session terminated"
182+
(INVALID_REQUEST) to the waiter, the same session-expiry mapping as the POST path, so
183+
reconnect logic keyed on that error works across both (SDK-defined)."""
184+
185+
def handler(request: httpx2.Request) -> httpx2.Response:
186+
if request.method == "GET" and LAST_EVENT_ID in request.headers:
187+
return httpx2.Response(404)
188+
if request.method == "DELETE": # session termination on close
189+
return httpx2.Response(200)
190+
body = json.loads(request.content)
191+
return httpx2.Response(
192+
200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, headers={MCP_SESSION_ID: "sess-1"}
193+
)
194+
195+
with anyio.fail_after(5):
196+
async with (
197+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
198+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
199+
):
200+
# An initialize round-trip stores the session id the server stamps on its response.
201+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="initialize", params={})))
202+
assert isinstance(await read.receive(), SessionMessage)
203+
204+
await write.send(
205+
SessionMessage(
206+
message=JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/call", params={}),
207+
metadata=ClientMessageMetadata(resumption_token="token-1"),
208+
)
209+
)
210+
reply = await read.receive()
211+
assert isinstance(reply, SessionMessage)
212+
assert isinstance(reply.message, JSONRPCError)
213+
assert reply.message.id == 2
214+
assert reply.message.error.code == INVALID_REQUEST
215+
assert reply.message.error.message == snapshot("Session terminated")
216+
217+
218+
@pytest.mark.anyio
219+
async def test_notification_with_resumption_token_is_posted_not_resumed() -> None:
220+
"""A notification stamped with a resumption token is POSTed like any notification, and the
221+
write loop survives to serve the next request (SDK-defined: the token names an interrupted
222+
request's stream, so resumption applies to requests only)."""
223+
recorded: list[httpx2.Request] = []
224+
225+
def handler(request: httpx2.Request) -> httpx2.Response:
226+
recorded.append(request)
227+
body = json.loads(request.content)
228+
if "id" not in body:
229+
return httpx2.Response(202)
230+
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
231+
232+
with anyio.fail_after(5):
233+
async with (
234+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
235+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
236+
):
237+
await write.send(
238+
SessionMessage(
239+
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/foo", params={}),
240+
metadata=ClientMessageMetadata(resumption_token="token-1"),
241+
)
242+
)
243+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={})))
244+
reply = await read.receive()
245+
assert isinstance(reply, SessionMessage)
246+
assert isinstance(reply.message, JSONRPCResponse)
247+
assert reply.message.id == 1
248+
# The stamped notification went out as a plain POST, not a resumption GET.
249+
assert [r.method for r in recorded] == ["POST", "POST"]
250+
251+
177252
@pytest.mark.anyio
178253
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
179254
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
@@ -674,6 +749,74 @@ def handler(request: httpx2.Request) -> httpx2.Response:
674749
assert reply.message.error.code == CONNECTION_CLOSED
675750

676751

752+
@pytest.mark.anyio
753+
async def test_resumption_stream_dying_mid_read_resolves_caller_and_transport_survives() -> None:
754+
"""A resumption GET stream that dies mid-read resolves the waiter with CONNECTION_CLOSED
755+
and the transport stays usable for follow-up requests (SDK-defined; #2110 — the read error
756+
used to escape into the task group and tear down every stream)."""
757+
dying = _DyingSSEStream()
758+
759+
def handler(request: httpx2.Request) -> httpx2.Response:
760+
if request.method == "GET" and LAST_EVENT_ID in request.headers:
761+
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
762+
body = json.loads(request.content)
763+
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
764+
765+
with anyio.fail_after(5):
766+
async with (
767+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
768+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
769+
):
770+
await write.send(
771+
SessionMessage(
772+
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
773+
metadata=ClientMessageMetadata(resumption_token="token-1"),
774+
)
775+
)
776+
reply = await read.receive()
777+
assert isinstance(reply, SessionMessage)
778+
assert isinstance(reply.message, JSONRPCError)
779+
assert reply.message.id == 1
780+
assert reply.message.error.code == CONNECTION_CLOSED
781+
assert reply.message.error.message == snapshot("resumption stream ended without a response")
782+
783+
# The transport survived: a plain follow-up request still round-trips.
784+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={})))
785+
follow_up = await read.receive()
786+
assert isinstance(follow_up, SessionMessage)
787+
assert isinstance(follow_up.message, JSONRPCResponse)
788+
assert follow_up.message.id == 2
789+
790+
791+
@pytest.mark.anyio
792+
async def test_resumption_stream_clean_end_without_response_resolves_caller() -> None:
793+
"""A resumption GET stream that closes cleanly without delivering a response (e.g. the
794+
server no longer holds the resumed request's events) resolves the waiter with an error
795+
instead of hanging it forever (SDK-defined; #2110)."""
796+
797+
def handler(request: httpx2.Request) -> httpx2.Response:
798+
assert request.method == "GET" and LAST_EVENT_ID in request.headers
799+
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, content=b": nothing to replay\n\n")
800+
801+
with anyio.fail_after(5):
802+
async with (
803+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
804+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
805+
):
806+
await write.send(
807+
SessionMessage(
808+
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
809+
metadata=ClientMessageMetadata(resumption_token="token-1"),
810+
)
811+
)
812+
reply = await read.receive()
813+
assert isinstance(reply, SessionMessage)
814+
assert isinstance(reply.message, JSONRPCError)
815+
assert reply.message.id == 1
816+
assert reply.message.error.code == CONNECTION_CLOSED
817+
assert reply.message.error.message == snapshot("resumption stream ended without a response")
818+
819+
677820
class _DeliverOnCommandSSEStream(httpx2.AsyncByteStream):
678821
"""Parks after opening, then delivers one JSON-RPC response when told."""
679822

0 commit comments

Comments
 (0)