deer-flow/backend/tests/test_mcp_per_server_discovery.py
Nan Gao f39cdd6077
fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances (#7207)
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances

MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.

- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
  per refresh_token connection keyed (scope, owner_user_id, server_key):
  ('deployment', '', name) or ('personal', owner, personal runtime
  name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
  ciphertext; a config fingerprint (token_url, client_id, grant, scope,
  audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
  peer's valid token, and otherwise claims the refresh with a lease plus
  a version compare-and-set (database clock), fetches outside any
  transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
  LangGraph runtime for sqlite/postgres; personal managers pass their
  owner. client_credentials stays in process; an unloadable key falls
  back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
  declared multi-instance deployment needs the shared key.

Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).

* docs(changelog): link the MCP OAuth token store entry to #7207

* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant

Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
  save removes (deployment: _commit_mcp_config_write; personal:
  _mutate, keeping each connection's enabled/disabled runtime-name
  variants so a toggle keeps its chain). Best effort; no startup sweep,
  since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
  McpOAuthReauthorizationRequired: the tool error names the server and
  asks for a new refresh token, plus the shared-key hint when this
  instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.

* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters

Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
  holder now renews its lease every third of REFRESH_LEASE_SECONDS while
  the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
  waiters outlast a live holder. Regression uses a real loopback endpoint
  that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
  (unsaved_base_version): they are written back before an older stored
  token is adopted or refreshed with, retried every few seconds while the
  access token is still valid, and dropped once a peer stored a newer
  chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
  a stored access token is reused only under the same request parameters;
  otherwise the stored refresh-token chain is refreshed.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 18:17:29 +08:00

757 lines
27 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_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