diff --git a/src/google/adk/tools/mcp_tool/mcp_session_manager.py b/src/google/adk/tools/mcp_tool/mcp_session_manager.py index c9c4c2ae66..18e90d7141 100644 --- a/src/google/adk/tools/mcp_tool/mcp_session_manager.py +++ b/src/google/adk/tools/mcp_tool/mcp_session_manager.py @@ -363,6 +363,25 @@ async def create_session( logger.debug('Created new session: %s', session_key) return session + except asyncio.CancelledError as e: + # CancelledError can occur when the 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+, it must be caught explicitly. + logger.warning( + 'MCP session creation cancelled (likely due to HTTP error): %s', e + ) + try: + await exit_stack.aclose() + except Exception as exit_stack_error: + logger.warning( + 'Error during cancelled session cleanup: %s', exit_stack_error + ) + raise ConnectionError( + f'MCP session creation cancelled (server may have returned HTTP' + f' error): {e}' + ) from e + except Exception as e: # If session creation fails, clean up the exit stack if exit_stack: diff --git a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py index 74eabe9d4d..862df0af1f 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py @@ -290,6 +290,42 @@ async def test_create_session_timeout( # Verify cleanup was called mock_exit_stack.aclose.assert_called_once() + @pytest.mark.asyncio + @patch("google.adk.tools.mcp_tool.mcp_session_manager.stdio_client") + @patch("google.adk.tools.mcp_tool.mcp_session_manager.AsyncExitStack") + async def test_create_session_cancelled_error( + self, mock_exit_stack_class, mock_stdio + ): + """Test session creation when CancelledError is raised (e.g., HTTP 403). + + When an MCP server returns an HTTP error (e.g., 401, 403), the MCP SDK + uses anyio cancel scopes internally, which raise CancelledError. This + test verifies that CancelledError is caught and converted to a + ConnectionError with proper cleanup. + """ + manager = MCPSessionManager(self.mock_stdio_connection_params) + + mock_exit_stack = MockAsyncExitStack() + + mock_exit_stack_class.return_value = mock_exit_stack + mock_stdio.return_value = AsyncMock() + + # Simulate CancelledError during session creation (e.g., HTTP 403) + mock_exit_stack.enter_async_context.side_effect = asyncio.CancelledError( + "Cancelled by cancel scope" + ) + + # Expect ConnectionError due to CancelledError + with pytest.raises( + ConnectionError, match="MCP session creation cancelled" + ): + await manager.create_session() + + # Verify session was not added to pool + assert not manager._sessions + # Verify cleanup was called + mock_exit_stack.aclose.assert_called_once() + @pytest.mark.asyncio async def test_close_success(self): """Test successful cleanup of all sessions."""