mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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>
757 lines
27 KiB
Python
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
|