From c6f1bacbe5f32ec56bb10edc574333ca80c2c733 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:31:46 -0500 Subject: [PATCH] fix: preserve exception propagation through transport cleanup anyio task groups suppress exceptions when cancel_scope.cancel() is called during cleanup. Capture exceptions before cleanup and re-raise after task group exits cleanly. Also preserve McpError type in client _connect() so callers can catch protocol-level errors specifically. --- src/fastmcp/client/client.py | 5 +++-- src/fastmcp/client/transports.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4651edadbe..c498039e9a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -16,7 +16,7 @@ import mcp.types import pydantic_core from exceptiongroup import catch -from mcp import ClientSession +from mcp import ClientSession, McpError from mcp.types import ( CancelTaskRequest, CancelTaskRequestParams, @@ -514,7 +514,8 @@ async def _connect(self): raise RuntimeError( "Session task completed without exception but connection failed" ) - if isinstance(exception, httpx.HTTPStatusError): + # Preserve specific exception types that clients may want to handle + if isinstance(exception, httpx.HTTPStatusError | McpError): raise exception raise RuntimeError( f"Client failed to connect: {exception}" diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index a6336d123a..eff3e2ba99 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -866,7 +866,11 @@ async def connect_session( client_read, client_write = client_streams server_read, server_write = server_streams - # Create a cancel scope for the server task + # Capture exceptions to re-raise after task group cleanup. + # anyio task groups can suppress exceptions when cancel_scope.cancel() + # is called during cleanup, so we capture and re-raise manually. + exception_to_raise: BaseException | None = None + async with ( anyio.create_task_group() as tg, _enter_server_lifespan(server=self.server), @@ -892,9 +896,15 @@ async def connect_session( **session_kwargs, ) as client_session: yield client_session + except BaseException as e: + exception_to_raise = e finally: tg.cancel_scope.cancel() + # Re-raise after task group has exited cleanly + if exception_to_raise is not None: + raise exception_to_raise + def __repr__(self) -> str: return f""