fix(mcp): isolate tool discovery failures (#3772)

This commit is contained in:
Huixin615 2026-06-25 22:57:00 +08:00 committed by GitHub
parent b0ce4b513c
commit 73874a9b35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 4 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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."""