diff --git a/backend/packages/harness/deerflow/mcp/tools.py b/backend/packages/harness/deerflow/mcp/tools.py index e4dc4f623..8799d026a 100644 --- a/backend/packages/harness/deerflow/mcp/tools.py +++ b/backend/packages/harness/deerflow/mcp/tools.py @@ -615,9 +615,20 @@ async def get_mcp_tools() -> list[BaseTool]: tool_name_prefix=True, ) - # Get all tools from all servers (discovers tool definitions via - # temporary sessions – the persistent-session wrapping is applied below). - tools = await client.get_tools() + async def load_server_tools(server_name: str) -> list[BaseTool]: + try: + return await client.get_tools(server_name=server_name) + except Exception as e: + logger.warning( + f"Skipping MCP server '{server_name}' after tool discovery failed: {e}", + exc_info=True, + ) + return [] + + # Get tools from each server independently so one broken MCP server does + # not prevent healthy servers from contributing their tools. + tools_by_server = await asyncio.gather(*(load_server_tools(name) for name in servers_config)) + tools = [tool for server_tools in tools_by_server for tool in server_tools] logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers") # Wrap each tool with persistent-session logic. diff --git a/backend/tests/test_mcp_session_pool.py b/backend/tests/test_mcp_session_pool.py index 9bc84fb10..8525007ce 100644 --- a/backend/tests/test_mcp_session_pool.py +++ b/backend/tests/test_mcp_session_pool.py @@ -889,7 +889,15 @@ async def test_http_transport_tools_not_pooled(): patch("langchain_mcp_adapters.sessions.create_session", return_value=mock_cm), ): mock_client_instance = MockClient.return_value - mock_client_instance.get_tools = AsyncMock(return_value=[http_tool, stdio_tool]) + + async def get_tools_for_server(*, server_name: str | None = None): + if server_name == "myserver": + return [http_tool] + if server_name == "playwright": + return [stdio_tool] + raise AssertionError(f"unexpected server_name: {server_name}") + + mock_client_instance.get_tools = AsyncMock(side_effect=get_tools_for_server) tools = await get_mcp_tools() diff --git a/backend/tests/test_mcp_sync_wrapper.py b/backend/tests/test_mcp_sync_wrapper.py index c66662bb5..3e909594b 100644 --- a/backend/tests/test_mcp_sync_wrapper.py +++ b/backend/tests/test_mcp_sync_wrapper.py @@ -53,6 +53,46 @@ def test_mcp_tool_sync_wrapper_generation(): assert result == "result: 42" +def test_mcp_tool_loading_skips_failed_server(): + """A broken MCP server should not drop tools from healthy servers.""" + + async def mock_coro(x: int): + return f"result: {x}" + + good_tool = StructuredTool( + name="good-server_search", + description="search from healthy server", + args_schema=MockArgs, + func=None, + coroutine=mock_coro, + ) + + async def get_tools_for_server(*, server_name: str | None = None): + if server_name == "good-server": + return [good_tool] + if server_name == "bad-server": + raise RuntimeError("SSE endpoint returned text/html") + raise AssertionError(f"unexpected server_name: {server_name}") + + mock_client_instance = MagicMock() + mock_client_instance.get_tools = AsyncMock(side_effect=get_tools_for_server) + + with ( + patch("langchain_mcp_adapters.client.MultiServerMCPClient", return_value=mock_client_instance), + patch("deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=MagicMock(model_extra={})), + patch("deerflow.mcp.tools.build_servers_config", return_value={"good-server": {}, "bad-server": {}}), + patch("deerflow.mcp.tools.get_initial_oauth_headers", new_callable=AsyncMock, return_value={}), + patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=None), + patch("deerflow.mcp.tools.logger.warning") as mock_warning, + ): + tools = asyncio.run(get_mcp_tools()) + + assert [tool.name for tool in tools] == ["good-server_search"] + assert tools[0].func is not None + mock_warning.assert_called_once() + assert "bad-server" in mock_warning.call_args[0][0] + + def test_mcp_tool_sync_wrapper_in_running_loop(): """Test the shared sync wrapper from production code."""