Skip to content

Commit 7d5be08

Browse files
committed
fix(mcp): handle CancelledError in MCPSessionManager.create_session
When an MCP server returns an HTTP error (e.g., 401, 403), the MCP SDK uses anyio cancel scopes internally, which raise CancelledError. Since CancelledError is a BaseException (not Exception) in Python 3.8+, the existing `except Exception` block does not catch it. This causes the error to propagate uncaught, leading to: - Application hangs - ASGI callable returned without completing response errors - Inability to handle MCP connection failures gracefully This fix explicitly catches asyncio.CancelledError and converts it to a ConnectionError with a descriptive message, allowing proper error handling and cleanup. Fixes #3708
1 parent a9b853f commit 7d5be08

File tree

2 files changed

+58
-0
lines changed

2 files changed

+58
-0
lines changed

src/google/adk/tools/mcp_tool/mcp_session_manager.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,26 @@ async def create_session(
363363
logger.debug('Created new session: %s', session_key)
364364
return session
365365

366+
except asyncio.CancelledError as e:
367+
# CancelledError can occur when the MCP server returns an HTTP error
368+
# (e.g., 401, 403). The MCP SDK uses anyio cancel scopes internally,
369+
# which raise CancelledError. Since CancelledError is a BaseException
370+
# (not Exception) in Python 3.8+, it must be caught explicitly.
371+
logger.warning(
372+
'MCP session creation cancelled (likely due to HTTP error): %s', e
373+
)
374+
if exit_stack:
375+
try:
376+
await exit_stack.aclose()
377+
except Exception as exit_stack_error:
378+
logger.warning(
379+
'Error during cancelled session cleanup: %s', exit_stack_error
380+
)
381+
raise ConnectionError(
382+
f'MCP session creation cancelled (server may have returned HTTP'
383+
f' error): {e}'
384+
) from e
385+
366386
except Exception as e:
367387
# If session creation fails, clean up the exit stack
368388
if exit_stack:

tests/unittests/tools/mcp_tool/test_mcp_session_manager.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,44 @@ async def test_create_session_timeout(
290290
# Verify cleanup was called
291291
mock_exit_stack.aclose.assert_called_once()
292292

293+
@pytest.mark.asyncio
294+
@patch("google.adk.tools.mcp_tool.mcp_session_manager.stdio_client")
295+
@patch("google.adk.tools.mcp_tool.mcp_session_manager.AsyncExitStack")
296+
@patch("google.adk.tools.mcp_tool.mcp_session_manager.ClientSession")
297+
async def test_create_session_cancelled_error(
298+
self, mock_session_class, mock_exit_stack_class, mock_stdio
299+
):
300+
"""Test session creation when CancelledError is raised (e.g., HTTP 403).
301+
302+
When an MCP server returns an HTTP error (e.g., 401, 403), the MCP SDK
303+
uses anyio cancel scopes internally, which raise CancelledError. This
304+
test verifies that CancelledError is caught and converted to a
305+
ConnectionError with proper cleanup.
306+
"""
307+
manager = MCPSessionManager(self.mock_stdio_connection_params)
308+
309+
mock_session = MockClientSession()
310+
mock_exit_stack = MockAsyncExitStack()
311+
312+
mock_exit_stack_class.return_value = mock_exit_stack
313+
mock_stdio.return_value = AsyncMock()
314+
315+
# Simulate CancelledError during session creation (e.g., HTTP 403)
316+
mock_exit_stack.enter_async_context.side_effect = asyncio.CancelledError(
317+
"Cancelled by cancel scope"
318+
)
319+
320+
# Expect ConnectionError due to CancelledError
321+
with pytest.raises(
322+
ConnectionError, match="MCP session creation cancelled"
323+
):
324+
await manager.create_session()
325+
326+
# Verify session was not added to pool
327+
assert not manager._sessions
328+
# Verify cleanup was called
329+
mock_exit_stack.aclose.assert_called_once()
330+
293331
@pytest.mark.asyncio
294332
async def test_close_success(self):
295333
"""Test successful cleanup of all sessions."""

0 commit comments

Comments
 (0)