Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(client): surface HTTP errors on resumption GET and SSE message POST#3278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base:main
Are you sure you want to change the base?
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
15b394ce5fe739b4edbd451d99af6472241af77621d25e6f866a9285File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -10,6 +10,7 @@ | ||
| fromanyio.abcimportTaskStatus | ||
| fromhttpx2importSSEError | ||
| frommcp.client._transportimportstatus_error_data | ||
| frommcp.shared._compatimportresync_tracer | ||
| frommcp.shared._context_streamsimportcreate_context_streams | ||
| frommcp.shared._httpx_utilsimportMcpHttpClientFactory, create_mcp_http_client | ||
| @@ -120,17 +121,52 @@ async def post_writer(endpoint_url: str): | ||
| asyncwithwrite_stream_reader, write_stream: | ||
| asyncdef_send_message(session_message: SessionMessage) ->None: | ||
| # A POST failure must not raise: the post_writer handler below | ||
| # would swallow it, hanging the waiting caller forever and killing | ||
| # the write loop (#2110). Mirror the streamable-HTTP transport | ||
| # instead: resolve the waiter with an error correlated to its | ||
| # request id, keeping the session usable. | ||
| logger.debug(f"Sending client message: {session_message}") | ||
| response=awaitclient.post( | ||
| endpoint_url, | ||
| json=session_message.message.model_dump( | ||
| by_alias=True, | ||
| mode="json", | ||
| exclude_unset=True, | ||
| ), | ||
| ) | ||
| response.raise_for_status() | ||
| logger.debug(f"Client message sent successfully: {response.status_code}") | ||
| message=session_message.message | ||
| try: | ||
| response=awaitclient.post( | ||
| endpoint_url, | ||
| json=message.model_dump( | ||
| by_alias=True, | ||
| mode="json", | ||
| exclude_unset=True, | ||
| ), | ||
| ) | ||
| exceptExceptionasexc: | ||
| # Terminal containment boundary: beyond httpx's own errors, | ||
Comment on lines
+124
to
+141
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing issue (not introduced by this PR, but in code it rewrites end-to-end): Extended reasoning...What the bug is. ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — the SSE message POST dropping Generated by Claude Code | ||
| # user-supplied auth flows and hooks can raise arbitrary types | ||
| # from inside `client.post()`, so an enumerated catch cannot | ||
| # keep the caller from hanging. | ||
| logger.exception("Error POSTing message") | ||
| error=types.ErrorData( | ||
| code=types.CONNECTION_CLOSED, message=f"Failed to send message: {exc}" | ||
| ) | ||
claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| else: | ||
| ifresponse.is_success: | ||
| logger.debug(f"Client message sent successfully: {response.status_code}") | ||
| return | ||
| logger.error(f"Message POST returned HTTP status {response.status_code}") | ||
Comment on lines
+150
to
+153
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Under the default client factory (create_mcp_http_client sets follow_redirects=True), a Location-bearing 301/302/303 on the SSE message POST is followed by httpx2, which rewrites the POST to a bodyless GET — so the JSON-RPC message is silently dropped, and a 2xx at the redirect target (e.g. an SSO login page) makes response.is_success pass, logging 'sent successfully' while the waiting caller hangs forever: the residual #2110 hang the is_success widening does not close, since the PR's 302 tests use Location-less responses that httpx cannot follow. Consider treating a method-rewriting redirect as a delivery failure (e.g. response.history non-empty and final request method != POST) and resolving the waiter via the same correlated path — method-preserving 307/308 keep working. Extended reasoning...What the bug is. The new success check at src/mcp/client/sse.py:150 — ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Real residual case — but behaviorally pre-existing per your own analysis: v1 passed identically on the followed 200, so this PR neither introduces nor worsens it. Treating method-rewriting redirects (301/302/303 turning the POST into a GET) as delivery failures is a deliberate behavior change, and it belongs in the grouped follow-up rather than another round here. Adding it to that follow-up's list alongside the reconnection-loop Generated by Claude Code | ||
| # The endpoint URL carrying a session id is this transport's | ||
| # "session established" signal, as `self.session_id` is for | ||
| # streamable HTTP. | ||
| error=status_error_data( | ||
| response.status_code, | ||
| has_session=_extract_session_id_from_endpoint(endpoint_url) isnotNone, | ||
| ) | ||
| # A notification has no waiter to resolve, so its failure is only logged. | ||
claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ifisinstance(message, types.JSONRPCRequest): | ||
| reply=types.JSONRPCError(jsonrpc="2.0", id=message.id, error=error) | ||
| try: | ||
| awaitread_stream_writer.send(SessionMessage(reply)) | ||
| except (anyio.BrokenResourceError, anyio.ClosedResourceError): | ||
| # Teardown race: the reader is gone, so there is nobody | ||
| # left to resolve - contain it, keeping the write loop up. | ||
| logger.debug("read stream closed before request %r could be resolved", message.id) | ||
| asyncforsession_messageinwrite_stream_reader: | ||
claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| sender_ctx=write_stream_reader.last_context | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -14,7 +14,6 @@ | ||
| from httpx2 import EventSource, ServerSentEvent | ||
| from mcp_types import ( | ||
| CONNECTION_CLOSED, | ||
| INTERNAL_ERROR, | ||
| INVALID_REQUEST, | ||
| METHOD_NOT_FOUND, | ||
| PARSE_ERROR, | ||
| @@ -30,7 +29,7 @@ | ||
| from mcp_types.version import MODERN_PROTOCOL_VERSIONS | ||
| from pydantic import ValidationError | ||
| from mcp.client._transport import TransportStreams | ||
| from mcp.client._transport import TransportStreams, status_error_data | ||
| from mcp.shared._compat import resync_tracer | ||
| from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams | ||
| from mcp.shared._httpx_utils import create_mcp_http_client | ||
| @@ -248,25 +247,44 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: | ||
| else: | ||
| raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover | ||
| # Extract original request ID to map responses | ||
| original_request_id = None | ||
| if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch | ||
| original_request_id = ctx.session_message.message.id | ||
| # Only requests resume: post_writer dispatches here on message type as well as | ||
| # metadata, so the original id is always available to map responses. | ||
| assert isinstance(ctx.session_message.message, JSONRPCRequest) | ||
| original_request_id = ctx.session_message.message.id | ||
| async with ctx.client.sse(self.url, headers=headers) as event_source: | ||
| event_source.response.raise_for_status() | ||
| logger.debug("Resumption GET SSE connection established") | ||
| try: | ||
| async with ctx.client.sse(self.url, headers=headers) as event_source: | ||
| if not event_source.response.is_success: | ||
| # Resolve the waiting caller with an error correlated to its request, | ||
| # mirroring `_handle_post_request`: an escaping `HTTPStatusError` would | ||
| # tear down the transport's task group and every stream with it (#2110). | ||
| error_data = status_error_data( | ||
| event_source.response.status_code, has_session=self.session_id is not None | ||
Comment on lines
+250
to
+262
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing issue (not introduced by this PR, but in code this PR rewrites end-to-end): Extended reasoning...What the bug is. ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — the resumption and reconnection GETs building headers from Generated by Claude Code | ||
| ) | ||
| await self._resolve_abandoned_request( | ||
| ctx.read_stream_writer, original_request_id, error_data.message, code=error_data.code | ||
| ) | ||
| return | ||
| logger.debug("Resumption GET SSE connection established") | ||
| async for sse in event_source: # pragma: no branch | ||
| is_complete = await self._handle_sse_event( | ||
| sse, | ||
| ctx.read_stream_writer, | ||
| original_request_id, | ||
| ctx.metadata.on_resumption_token_update if ctx.metadata else None, | ||
| ) | ||
| if is_complete: | ||
| await event_source.response.aclose() | ||
| break | ||
| async for sse in event_source: | ||
| is_complete = await self._handle_sse_event( | ||
| sse, | ||
| ctx.read_stream_writer, | ||
| original_request_id, | ||
Comment on lines
+261
to
+274
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing issue (not introduced by this PR): the automatic Last-Event-ID reconnection GET in Extended reasoning...What the bug is. This PR establishes a cross-path contract — a 404 while a session is held maps to ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed this is a real gap — but it's pre-existing, on a path this PR doesn't rewrite ( Generated by Claude Code | ||
| ctx.metadata.on_resumption_token_update if ctx.metadata else None, | ||
| ) | ||
| if is_complete: | ||
| await event_source.response.aclose() | ||
| return | ||
| except Exception: | ||
| logger.debug("Resumption stream ended", exc_info=True) | ||
| # Stream ended without a response, cleanly or mid-read: resolve the waiter, | ||
| # mirroring `_handle_sse_response`, else the caller would hang forever. | ||
| await self._resolve_abandoned_request( | ||
| ctx.read_stream_writer, original_request_id, "resumption stream ended without a response" | ||
| ) | ||
| def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: | ||
| """Translate an outbound `notifications/cancelled` at 2026; True means "do not POST". | ||
| @@ -358,16 +376,13 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: | ||
| except (httpx2.StreamError, ValidationError): | ||
| pass | ||
| logger.debug("Non-2xx body was not a JSON-RPC error; using fallback") | ||
| if response.status_code == 404: | ||
| if self.session_id is None: | ||
| # No session yet → 404 is the HTTP-level spelling of | ||
| # METHOD_NOT_FOUND (gateway / legacy server doesn't know | ||
| # this method); "Session terminated" would be a lie here. | ||
| error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found") | ||
| else: | ||
| error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") | ||
| if response.status_code == 404 and self.session_id is None: | ||
| # No session yet → 404 is the HTTP-level spelling of | ||
| # METHOD_NOT_FOUND (gateway / legacy server doesn't know | ||
| # this method); "Session terminated" would be a lie here. | ||
| error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found") | ||
| else: | ||
| error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") | ||
| error_data = status_error_data(response.status_code, has_session=self.session_id is not None) | ||
| session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) | ||
| await ctx.read_stream_writer.send(session_message) | ||
| return | ||
Comment on lines
384
to
388
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing issue (not introduced by this PR): the five reply sends on the streamable POST path — Extended reasoning...What the bug is. The reply-delivery sends on the streamable POST path are the last uncontained ones on the client transports. In ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed the asymmetry is real: this PR's new resolution paths are contained against the teardown race while the five pre-existing reply sends on the POST path are not. That gap predates this PR and spans code it doesn't otherwise touch, so declining here rather than growing the diff further — it's a natural member of a grouped follow-up PR with this pass's other two pre-existing findings (the reconnection loop's bare Generated by Claude Code | ||
| @@ -556,8 +571,10 @@ async def _handle_message(session_message: SessionMessage) -> None: | ||
| else None | ||
| ) | ||
| # Check if this is a resumption request | ||
| is_resumption = bool(metadata and metadata.resumption_token) | ||
| # Only a request resumes: the token names an interrupted request's | ||
| # stream, and `_handle_resumption_request` needs the id to correlate | ||
| # its outcome. A notification stamped with one is POSTed as usual. | ||
| is_resumption = bool(metadata and metadata.resumption_token) and isinstance(message, JSONRPCRequest) | ||
| logger.debug(f"Sending client message: {message}") | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.