deer-flow/backend/tests/test_mcp_per_server_discovery.py
hataa 7892f79f9c
fix(mcp): drop cross-server duplicate bound tool names instead of silently shadowing (#8135)
Server-prefixed tool names are not unique across servers: server 'a'
exposing 'b_x' and server 'a_b' exposing 'x' both bind as 'a_b_x'. The
flatten step forwarded both declarations to the model, where the provider
silently keeps only one and the other server's tool becomes unreachable
with no diagnostic.

Deduplicate by bound name in enabled-server declaration order and drop
later duplicates with a warning.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 19:07:32 +08:00

790 lines
29 KiB
Python

"""F1 coverage for grouped, per-server MCP tool discovery."""
from __future__ import annotations
import asyncio
import builtins
import logging
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from langchain_core.tools import StructuredTool
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.oauth import get_initial_oauth_headers
from deerflow.mcp.session_pool import ServerBinding, StaleMCPBindingError, normalized_connection_fingerprint
from deerflow.mcp.tasks.runtime import McpTaskConfigurationError
from deerflow.mcp.tools import (
ServerDiscoveryResult,
_flatten_server_tool_groups,
get_mcp_tools,
get_mcp_tools_by_server,
)
def _tool(name: str) -> StructuredTool:
async def _call() -> str:
return name
return StructuredTool.from_function(coroutine=_call, name=name, description=name)
def _config(servers: dict[str, dict]) -> ExtensionsConfig:
return ExtensionsConfig.model_validate({"mcpServers": servers})
def _patch_common(monkeypatch, *, oauth_headers=None, interceptors=None):
headers = oauth_headers or AsyncMock(return_value={})
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", headers)
monkeypatch.setattr(
"deerflow.mcp.tools.build_mcp_tool_interceptors",
interceptors or (lambda *args, **kwargs: []),
)
return headers
def test_flatten_server_tool_groups_preserves_enabled_order_and_empty_success():
a, b = _tool("a"), _tool("b")
groups = {
"A": ServerDiscoveryResult(tools=(a,)),
"B": ServerDiscoveryResult(tools=(b,)),
"EMPTY": ServerDiscoveryResult(tools=()),
}
flattened = _flatten_server_tool_groups(("B", "EMPTY", "A", "ABSENT"), groups)
assert flattened == [b, a]
assert flattened[0] is b
assert flattened[1] is a
assert "EMPTY" in groups
assert groups["EMPTY"].tools == ()
def test_flatten_server_tool_groups_drops_cross_server_duplicate_names(caplog):
"""Server 'a' exposing 'b_x' and server 'a_b' exposing 'x' both bind 'a_b_x'."""
a_b_x = _tool("a_b_x")
duplicate = _tool("a_b_x")
other = _tool("a_other")
groups = {
"a": ServerDiscoveryResult(tools=(a_b_x, other)),
"a_b": ServerDiscoveryResult(tools=(duplicate,)),
}
with caplog.at_level(logging.WARNING, logger="deerflow.mcp.tools"):
flattened = _flatten_server_tool_groups(("a", "a_b"), groups)
assert flattened == [a_b_x, other]
assert flattened[0] is a_b_x
assert duplicate not in flattened
assert "a_b_x" in caplog.text
assert "'a_b'" in caplog.text
def test_flatten_server_tool_groups_drops_duplicate_within_one_server(caplog):
first = _tool("A_search")
second = _tool("A_search")
groups = {"A": ServerDiscoveryResult(tools=(first, second))}
with caplog.at_level(logging.WARNING, logger="deerflow.mcp.tools"):
flattened = _flatten_server_tool_groups(("A",), groups)
assert flattened == [first]
assert "A_search" in caplog.text
assert "already served by server 'A'" in caplog.text
def test_server_discovery_result_repr_hides_ownership_handles():
pool = MagicMock()
binding = ServerBinding(
domain="deployment",
server_name="A",
epoch=7,
fingerprint="secret-fingerprint",
)
rendered = repr(ServerDiscoveryResult(tools=(), pool=pool, binding=binding))
assert "pool" not in rendered
assert "binding" not in rendered
assert "secret-fingerprint" not in rendered
@pytest.mark.asyncio
async def test_get_mcp_tools_config_load_errors_propagate(monkeypatch):
from_file = MagicMock(side_effect=FileNotFoundError("missing config"))
logger_error = MagicMock()
monkeypatch.setattr("deerflow.mcp.tools.ExtensionsConfig.from_file", from_file)
monkeypatch.setattr("deerflow.mcp.tools.logger.error", logger_error)
with pytest.raises(FileNotFoundError, match="missing config"):
await get_mcp_tools()
from_file.assert_called_once()
logger_error.assert_not_called()
@pytest.mark.asyncio
async def test_get_mcp_tools_missing_adapter_returns_before_side_effects(monkeypatch):
from_file = MagicMock(side_effect=AssertionError("config must not load"))
get_session_pool = MagicMock(side_effect=AssertionError("pool must not resolve"))
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name.startswith("langchain_mcp_adapters"):
raise ImportError("missing adapter")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr("deerflow.mcp.tools.ExtensionsConfig.from_file", from_file)
monkeypatch.setattr("deerflow.mcp.tools.get_session_pool", get_session_pool)
monkeypatch.setattr(builtins, "__import__", fake_import)
tools = await get_mcp_tools()
assert tools == []
from_file.assert_not_called()
get_session_pool.assert_not_called()
@pytest.mark.asyncio
async def test_wrap_failure_returns_empty_instead_of_partial_tools(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "stdio", "command": "a-server"},
"B": {"enabled": True, "type": "stdio", "command": "b-server"},
}
)
pool = MagicMock()
pool.ensure_binding.side_effect = lambda name, *_args, **_kwargs: ServerBinding(
domain="deployment",
server_name=name,
epoch=1,
fingerprint=f"fp-{name}",
)
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
return [_tool(f"{server_name}_search")]
def fake_wrap(tool, server_name, *args, **kwargs):
if server_name == "A":
raise RuntimeError("wrap failed")
return tool
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
_patch_common(monkeypatch)
monkeypatch.setattr("deerflow.mcp.tools._make_session_pool_tool", fake_wrap)
tools = await get_mcp_tools(config, session_pool=pool)
assert tools == []
@pytest.mark.asyncio
async def test_task_configuration_error_waits_for_sibling_discovery(monkeypatch):
config = _config(
{
"A": {
"enabled": True,
"type": "http",
"url": "https://a.example/mcp",
"task_toolsets": [
{
"name": "reports",
"submit_tool": "submit",
"status_tool": "status",
"cancel_tool": "cancel",
}
],
},
"B": {"enabled": True, "type": "http", "url": "https://b.example/mcp"},
}
)
sibling_finished = asyncio.Event()
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
if server_name == "B":
await asyncio.sleep(0.05)
sibling_finished.set()
return [_tool("B_search")]
return []
manager = MagicMock()
manager.has_oauth_servers.return_value = False
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_task_oauth_token_manager", lambda config: manager)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", AsyncMock(return_value={}))
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
with pytest.raises(McpTaskConfigurationError, match="missing raw tool"):
await get_mcp_tools_by_server(config, server_names={"A", "B"})
assert sibling_finished.is_set()
@pytest.mark.asyncio
async def test_selected_stdio_isolation_builds_one_client_and_only_its_binding(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "stdio", "command": "a-server"},
"B": {"enabled": True, "type": "stdio", "command": "b-server"},
}
)
created: list[set[str]] = []
wrapped: list[str] = []
oauth_selection: list[set[str]] = []
binding = ServerBinding(
domain="deployment",
server_name="A",
epoch=1,
fingerprint="fp-a",
)
pool = MagicMock()
pool.ensure_binding.return_value = binding
class FakeClient:
def __init__(self, connections, **kwargs):
created.append(set(connections))
self.connections = connections
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
assert server_name in self.connections
return [_tool(f"{server_name}_search")]
async def fake_oauth_headers(_config, *, token_manager=None, server_names=None):
oauth_selection.append(set(server_names or ()))
return {}
def fake_wrapper(tool, server_name, *args, **kwargs):
wrapped.append(server_name)
return tool
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", fake_oauth_headers)
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
monkeypatch.setattr("deerflow.mcp.tools._make_session_pool_tool", fake_wrapper)
monkeypatch.setattr("deerflow.mcp.tools.get_session_pool", MagicMock(side_effect=AssertionError("caller pool must win")))
result = await get_mcp_tools_by_server(
config,
server_names={"A"},
session_pool=pool,
)
assert created == [{"A"}]
assert list(result) == ["A"]
assert [tool.name for tool in result["A"].tools] == ["A_search"]
assert wrapped == ["A"]
assert oauth_selection == [{"A"}]
pool.ensure_binding.assert_called_once()
assert pool.ensure_binding.call_args.args[0] == "A"
assert pool.ensure_binding.call_args.args[1] == normalized_connection_fingerprint(
{"transport": "stdio", "command": "a-server", "args": []},
)
assert pool.ensure_binding.call_args.kwargs == {"domain": "deployment"}
assert result["A"].pool is pool
assert result["A"].binding is binding
@pytest.mark.asyncio
async def test_each_selected_server_gets_its_own_single_server_client(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "http", "url": "https://a.example/mcp"},
"B": {"enabled": True, "type": "http", "url": "https://b.example/mcp"},
"C": {"enabled": True, "type": "http", "url": "https://c.example/mcp"},
}
)
created: list[set[str]] = []
class FakeClient:
def __init__(self, connections, **kwargs):
created.append(set(connections))
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
return [_tool(f"{server_name}_search")]
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
_patch_common(monkeypatch)
result = await get_mcp_tools_by_server(config, server_names={"A", "B"})
assert created == [{"A"}, {"B"}]
assert list(result) == ["A", "B"]
assert [tool.name for tool in result["A"].tools] == ["A_search"]
assert [tool.name for tool in result["B"].tools] == ["B_search"]
@pytest.mark.asyncio
async def test_each_client_gets_interceptors_built_from_only_its_server(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "http", "url": "https://a.example/mcp"},
"B": {"enabled": True, "type": "http", "url": "https://b.example/mcp"},
}
)
clients: list[tuple[set[str], list[object]]] = []
built_for: list[tuple[str, ...]] = []
class FakeClient:
def __init__(self, connections, **kwargs):
interceptors = kwargs.get("tool_interceptors") or []
clients.append((set(connections), interceptors))
self.callbacks = None
self.tool_interceptors = interceptors
async def get_tools(self, *, server_name=None):
return [_tool(f"{server_name}_search")]
def fake_interceptors(server_config, *args, **kwargs):
built_for.append(tuple(server_config.get_enabled_mcp_servers()))
return [object()]
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", AsyncMock(return_value={}))
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", fake_interceptors)
result = await get_mcp_tools_by_server(config, server_names={"A", "B"})
assert list(result) == ["A", "B"]
assert [connections for connections, _ in clients] == [{"A"}, {"B"}]
assert sorted(built_for) == [("A",), ("B",)]
assert clients[0][1] is not clients[1][1]
@pytest.mark.asyncio
async def test_failed_discovery_is_absent_but_successful_empty_is_present(monkeypatch):
config = _config(
{
"EMPTY": {"enabled": True, "type": "http", "url": "https://empty.example/mcp"},
"FAIL": {"enabled": True, "type": "http", "url": "https://fail.example/mcp"},
}
)
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
if server_name == "FAIL":
raise RuntimeError("offline")
return []
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
_patch_common(monkeypatch)
result = await get_mcp_tools_by_server(config)
assert "EMPTY" in result
assert result["EMPTY"].tools == ()
assert "FAIL" not in result
@pytest.mark.asyncio
async def test_initial_oauth_headers_only_fetch_selected_names(monkeypatch):
config = _config(
{
"A": {
"enabled": True,
"type": "http",
"url": "https://a.example/mcp",
"oauth": {"enabled": True, "token_url": "https://auth.example/a"},
},
"B": {
"enabled": True,
"type": "http",
"url": "https://b.example/mcp",
"oauth": {"enabled": True, "token_url": "https://auth.example/b"},
},
}
)
manager = MagicMock()
manager.has_oauth_servers.return_value = True
manager.oauth_server_names.return_value = ["A", "B"]
manager.get_authorization_header = AsyncMock(side_effect=lambda name: f"Bearer {name}")
monkeypatch.setattr(
"deerflow.mcp.oauth.OAuthTokenManager.from_extensions_config",
lambda config: manager,
)
assert await get_initial_oauth_headers(config, server_names={"A"}) == {"A": "Bearer A"}
manager.get_authorization_header.assert_awaited_once_with("A")
manager.get_authorization_header.reset_mock()
assert await get_initial_oauth_headers(config, server_names=set()) == {}
manager.get_authorization_header.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_mcp_tools_flattens_grouped_discovery_in_enabled_order(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "http", "url": "https://a.example/mcp"},
"B": {"enabled": True, "type": "http", "url": "https://b.example/mcp"},
}
)
a, b = _tool("A_search"), _tool("B_search")
calls: list[object] = []
async def fake_grouped(_adapter_symbols, prepared):
calls.append(prepared)
return {
"B": ServerDiscoveryResult(tools=(b,)),
"A": ServerDiscoveryResult(tools=(a,)),
}
monkeypatch.setattr("deerflow.mcp.tools._discover_and_wrap_prepared", fake_grouped)
tools = await get_mcp_tools(config)
assert tools == [a, b]
assert len(calls) == 1
assert calls[0].config is config
@pytest.mark.asyncio
async def test_get_mcp_tools_full_discovery_preserves_name_order_and_loads_config_once(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "http", "url": "https://a.example/mcp"},
"B": {"enabled": True, "type": "http", "url": "https://b.example/mcp"},
}
)
a_first = _tool("A_first")
b_first = _tool("B_first")
b_second = _tool("B_second")
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
if server_name == "A":
return [a_first]
return [b_first, b_second]
from_file = MagicMock(return_value=config)
monkeypatch.setattr("deerflow.mcp.tools.ExtensionsConfig.from_file", from_file)
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
_patch_common(monkeypatch)
tools = await get_mcp_tools()
assert [tool.name for tool in tools] == ["A_first", "B_first", "B_second"]
assert tools == [a_first, b_first, b_second]
assert tools[0] is a_first
assert tools[1] is b_first
assert tools[2] is b_second
assert from_file.call_count == 1
@pytest.mark.asyncio
async def test_bindings_are_captured_before_the_first_discovery_await(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "stdio", "command": "a-server"},
}
)
order: list[str] = []
binding = ServerBinding(domain="deployment", server_name="A", epoch=1, fingerprint="fp-a")
pool = MagicMock()
def ensure_binding(*args, **kwargs):
order.append("bind")
return binding
pool.ensure_binding.side_effect = ensure_binding
async def fake_oauth_headers(*args, **kwargs):
assert order == ["bind"]
order.append("oauth")
return {}
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
assert order == ["bind", "oauth"]
order.append("discover")
return []
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", fake_oauth_headers)
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
result = await get_mcp_tools_by_server(config, session_pool=pool)
assert result["A"].tools == ()
assert result["A"].pool is pool
assert result["A"].binding is binding
assert order == ["bind", "oauth", "discover"]
pool.ensure_binding.assert_called_once()
@pytest.mark.asyncio
async def test_personal_discovery_uses_personal_pool_domain_and_task_scope(monkeypatch):
config = _config(
{
"A": {
"enabled": True,
"type": "stdio",
"command": "a-server",
"task_toolsets": [
{
"name": "reports",
"submit_tool": "submit",
"status_tool": "status",
"cancel_tool": "cancel",
}
],
}
}
)
pool = MagicMock()
binding = ServerBinding(domain="personal", server_name="A", epoch=1, fingerprint="fp-a")
pool.ensure_binding.return_value = binding
ownership_domains: list[str] = []
connection_scopes: list[str] = []
deployment_manager = MagicMock(side_effect=AssertionError("deployment manager used for personal discovery"))
personal_manager = MagicMock()
personal_manager.has_oauth_servers.return_value = False
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
return [_tool("A_submit")]
def fake_wrapper(tool, server_name, *args, **kwargs):
ownership_domains.append(kwargs["ownership_domain"])
return tool
def fake_configure(tools, *, server_name, server_config, tool_name_prefix, connection_scope="deployment"):
connection_scopes.append(connection_scope)
return tools
monkeypatch.setattr("deerflow.mcp.user_config.load_user_mcp_config", lambda owner: config)
monkeypatch.setattr(
"deerflow.mcp.personal_access.authorized_personal_config",
AsyncMock(return_value=config),
)
monkeypatch.setattr("deerflow.mcp.tasks.runtime.is_mcp_task_runtime_available", lambda: True)
oauth_owners: list[str | None] = []
def personal_manager_for(config, *, owner_user_id=None):
oauth_owners.append(owner_user_id)
return personal_manager
monkeypatch.setattr("deerflow.mcp.tools.OAuthTokenManager.from_extensions_config", personal_manager_for)
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_task_oauth_token_manager", deployment_manager)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", AsyncMock(return_value={}))
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools._make_session_pool_tool", fake_wrapper)
monkeypatch.setattr("deerflow.mcp.tools._configure_task_tools_for_server", fake_configure)
result = await get_mcp_tools_by_server(
config,
personal_user_id="alice",
session_pool=pool,
)
assert list(result) == ["A"]
assert pool.ensure_binding.call_args.kwargs == {"domain": "personal"}
assert ownership_domains == ["personal"]
assert connection_scopes == ["personal"]
# Stored OAuth tokens are keyed by the owner of a personal connection.
assert oauth_owners == ["alice"]
deployment_manager.assert_not_called()
@pytest.mark.asyncio
async def test_stale_binding_propagates_before_any_client_is_built(monkeypatch):
config = _config(
{
"A": {"enabled": True, "type": "stdio", "command": "a-server"},
}
)
pool = MagicMock()
pool.ensure_binding.side_effect = StaleMCPBindingError("A")
clients: list[set[str]] = []
class FakeClient:
def __init__(self, connections, **kwargs):
clients.append(set(connections))
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
with pytest.raises(StaleMCPBindingError):
await get_mcp_tools_by_server(config, session_pool=pool)
assert clients == []
@pytest.mark.asyncio
async def test_task_configuration_error_propagates_from_grouped_discovery(monkeypatch):
config = _config(
{
"A": {
"enabled": True,
"type": "http",
"url": "https://a.example/mcp",
"task_toolsets": [
{
"name": "reports",
"submit_tool": "submit",
"status_tool": "status",
"cancel_tool": "cancel",
}
],
}
}
)
class FakeClient:
def __init__(self, connections, **kwargs):
self.callbacks = None
self.tool_interceptors = kwargs.get("tool_interceptors") or []
async def get_tools(self, *, server_name=None):
return []
manager = MagicMock()
manager.has_oauth_servers.return_value = False
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_task_oauth_token_manager", lambda config: manager)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", AsyncMock(return_value={}))
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
with pytest.raises(McpTaskConfigurationError, match="missing raw tool"):
await get_mcp_tools_by_server(config, server_names={"A"})
@pytest.mark.asyncio
async def test_selection_matching_only_unknown_or_disabled_names(monkeypatch, caplog):
"""A selection that matches nothing is not "nothing is configured"."""
config = _config(
{
"A": {"enabled": True, "type": "http", "url": "https://a.example/mcp"},
"B": {"enabled": False, "type": "http", "url": "https://b.example/mcp"},
}
)
client_calls: list = []
monkeypatch.setattr(
"langchain_mcp_adapters.client.MultiServerMCPClient",
lambda *args, **kwargs: client_calls.append((args, kwargs)) or MagicMock(),
)
headers = AsyncMock(return_value={})
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", headers)
with caplog.at_level(logging.INFO, logger="deerflow.mcp.tools"):
assert await get_mcp_tools_by_server(config, server_names={"B", "UNKNOWN"}) == {}
assert client_calls == []
headers.assert_not_awaited()
assert "No enabled MCP servers matched the requested selection" in caplog.text
assert "No enabled MCP servers configured" not in caplog.text
@pytest.mark.asyncio
async def test_discovery_and_wrapper_use_the_same_session_init_timeout(monkeypatch):
"""Preparation derives the timeout once; discovery and wrapping share it."""
config = _config(
{
"A": {
"enabled": True,
"type": "stdio",
"command": "npx",
"args": [],
"session_init_timeout": 5,
}
}
)
observed: dict[str, float | None] = {}
class FakeClient:
def __init__(self, connections, **kwargs):
pass
async def get_tools(self, *, server_name=None):
return [_tool("A_search")]
real_wait_for = asyncio.wait_for
async def spy_wait_for(awaitable, timeout):
observed["discovery"] = timeout
return await real_wait_for(awaitable, timeout)
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
monkeypatch.setattr("deerflow.mcp.tools.asyncio.wait_for", spy_wait_for)
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", AsyncMock(return_value={}))
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *a, **kw: [])
real_make_tool = __import__("deerflow.mcp.tools", fromlist=["_make_session_pool_tool"])._make_session_pool_tool
def spy_make_tool(*args, **kwargs):
observed["wrapper"] = kwargs.get("session_init_timeout")
return real_make_tool(*args, **kwargs)
monkeypatch.setattr("deerflow.mcp.tools._make_session_pool_tool", spy_make_tool)
groups = await get_mcp_tools_by_server(config, server_names={"A"})
assert len(groups["A"].tools) == 1
assert observed["discovery"] == 5.0
assert observed["wrapper"] == observed["discovery"]
@pytest.mark.asyncio
async def test_per_server_discovery_failure_log_redacts_resolved_credentials(monkeypatch, caplog):
"""A failed per-server discovery must not log a credential-bearing URL."""
from langchain_mcp_adapters import client as adapter_client
secret = "s3cr3t-pass-123"
config = _config({"A": {"enabled": True, "type": "http", "url": "https://alice@remote.test/mcp"}})
_patch_common(monkeypatch)
class FakeClient:
def __init__(self, connections, **kwargs) -> None:
self.callbacks = kwargs.get("callbacks") or []
self.tool_interceptors = kwargs.get("tool_interceptors") or []
self.tool_name_prefix = kwargs.get("tool_name_prefix", True)
async def get_tools(self, *, server_name=None):
request = httpx.Request("GET", f"https://alice:{secret}@remote.test/mcp")
raise httpx.HTTPStatusError(
f"Client error for url '{request.url}'",
request=request,
response=httpx.Response(401, request=request),
)
monkeypatch.setattr(adapter_client, "MultiServerMCPClient", FakeClient)
with caplog.at_level("WARNING", logger="deerflow.mcp.tools"):
result = await get_mcp_tools_by_server(config, server_names={"A"})
assert result == {}
assert secret not in caplog.text # the resolved credential never reaches the log
assert "server 'A'" in caplog.text # the offending server name is retained
assert "HTTPStatusError" in caplog.text # exception type retained