mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(mcp): add per-server discovery cache scaffolding Introduce the per-server tool cache entry, canonical parsed-config snapshots on the applied revision, and the shared _binding_matches reuse predicate. Identity reuse is object-identity based on the pool's current binding (no value equality); snapshots and ownership handles stay out of repr. No selection, eviction or publication is wired yet. * feat(mcp): selectively evict stale per-server tool cache entries Evict per-server discovery results during selective reconciliation, after the new binding epoch is installed, so unchanged servers keep their exact entry objects while changed, removed or re-bound servers (including lifecycle-token rotations) are dropped. Delegate the binding verdict to _binding_matches so reuse and eviction cannot diverge. Clear _server_tool_cache on every full reset, and count retained entries in the reconciliation state predicates so a lingering entry can never let a later config change be silently skipped. * feat(mcp): reuse unchanged per-server results during initialization Initialize MCP tools from per-server discovery instead of a single full pass: select reusable entries under the claim lock, discover only the missing servers with get_mcp_tools_by_server, then publish atomically after re-validating every candidate binding against the pool's current binding via _binding_matches. Failed servers stay absent (never cached as empty), and superseded or partially bound candidates are discarded whole. Test fixtures now drive the grouped entry point. * fix(mcp): close T4 review gaps in per-server cache publication Address four findings from the adversarial review: treat retained entries as unverifiable when the applied baseline is None so an interceptor change still forces a full reset; count retained entries in the claim-time shared-reset predicate; refuse to publish a false-success empty cache when every enabled server failed discovery; and hoist the lazy imports inside the initializer's try/finally so an import failure releases the initialization claim. * fix(mcp): sanitize cache failure logging and document per-server cache The outer lazy-initialization handler now logs only the exception type, never the raw message or traceback, so a discovery error cannot print a resolved $VAR credential. The committed-writer planner mirrors the reader's no-baseline guard so retained entries are never reused under an unknown interceptor chain. Document the per-server tool cache (reuse, invalidation, failure handling, remote tool changes) in MCP_SERVER.md and mcp/AGENTS.md, and drop phase-staging wording from the new code and tests. * docs(mcp): keep the agent-guidance chain under its hard limit F2's cache notes pushed the effective AGENTS.md instruction chain 905 bytes past its 98304-byte hard limit, failing the agent-guidance CI job. Compress the selective-reconciliation bullet to its invariants (the full cache behaviour lives in docs/MCP_SERVER.md), and reword the total-failure comment so it no longer implies that build-invalid connections were attempted. * fix(mcp): fence cached tools by interceptor chain --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
838 lines
33 KiB
Python
838 lines
33 KiB
Python
"""Coverage for the per-server MCP discovery cache.
|
|
|
|
The cache may reuse a server's already-published ``ServerDiscoveryResult`` only
|
|
when all of these still hold:
|
|
|
|
* the server's canonical configuration identity is unchanged
|
|
(``_server_config_snapshot``),
|
|
* the normalized global interceptor chain is unchanged, and
|
|
* the discovery result is still owned by the pool's *current* session binding
|
|
(``_binding_matches``).
|
|
|
|
This module covers both the reuse predicates and their use by
|
|
``initialize_mcp_tools`` (selection, per-server rediscovery, atomic
|
|
publication).
|
|
|
|
``ServerBinding`` is declared ``eq=False``: two bindings with identical field
|
|
values are still distinct objects, and ABA safety depends on comparing object
|
|
identity (``pool._binding_is_current``), never dataclass value equality. The
|
|
superseded-binding test below would still pass under value equality and is
|
|
therefore the load-bearing one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import builtins
|
|
import json
|
|
import logging
|
|
import threading
|
|
|
|
import pytest
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.mcp import cache as cache_module
|
|
from deerflow.mcp import session_pool as session_pool_module
|
|
from deerflow.mcp.cache import (
|
|
_AppliedMcpRevision,
|
|
_binding_matches,
|
|
_ServerToolCacheEntry,
|
|
)
|
|
from deerflow.mcp.client import build_servers_config
|
|
from deerflow.mcp.session_pool import MCPSessionPool, normalized_connection_fingerprint
|
|
from deerflow.mcp.tools import ServerDiscoveryResult
|
|
|
|
|
|
def _config(servers: dict[str, dict]) -> ExtensionsConfig:
|
|
return ExtensionsConfig.model_validate({"mcpServers": servers})
|
|
|
|
|
|
def _server(config: ExtensionsConfig, name: str):
|
|
return config.get_enabled_mcp_servers()[name]
|
|
|
|
|
|
def _snapshot(servers: dict[str, dict], name: str) -> str:
|
|
return cache_module._server_config_snapshot(_server(_config(servers), name))
|
|
|
|
|
|
def _pool_with_binding(
|
|
name: str = "A",
|
|
fingerprint: str = "fp",
|
|
domain: str = "deployment",
|
|
) -> tuple[MCPSessionPool, object]:
|
|
pool = MCPSessionPool()
|
|
binding = pool.ensure_binding(name, fingerprint, domain=domain)
|
|
return pool, binding
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Canonical config identity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_server_snapshot_equal_across_transport_alias_and_field_order():
|
|
typed = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv", "args": ["x"]}}, "A")
|
|
aliased = _snapshot({"A": {"enabled": True, "transport": "stdio", "command": "srv", "args": ["x"]}}, "A")
|
|
reordered = _snapshot({"A": {"command": "srv", "args": ["x"], "enabled": True, "type": "stdio"}}, "A")
|
|
|
|
# ``transport`` is an MCP-spec alias for ``type`` and JSON key order is
|
|
# irrelevant: neither spelling difference may look like a config change.
|
|
assert typed == aliased == reordered
|
|
|
|
|
|
def test_server_snapshot_changes_on_presentation_metadata_change():
|
|
base = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv", "description": "one"}}, "A")
|
|
changed = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv", "description": "two"}}, "A")
|
|
|
|
# Presentation-only fields still change how tools are advertised, so the
|
|
# snapshot must distinguish them (metadata-only updates rebuild the tools).
|
|
assert base != changed
|
|
|
|
|
|
def test_server_snapshot_changes_on_routing_and_connection_change():
|
|
base = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv"}}, "A")
|
|
routed = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv", "routing": {"mode": "off", "priority": 5}}}, "A")
|
|
reconnected = _snapshot({"A": {"enabled": True, "type": "stdio", "command": "srv2"}}, "A")
|
|
|
|
assert base != routed
|
|
assert base != reconnected
|
|
|
|
|
|
def test_server_snapshot_is_stable_string():
|
|
snapshot = _snapshot({"A": {"enabled": True, "type": "http", "url": "https://example.test/mcp"}}, "A")
|
|
|
|
assert isinstance(snapshot, str) and snapshot
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Binding ownership identity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_binding_matches_accepts_http_result_without_pool_or_binding():
|
|
result = ServerDiscoveryResult(tools=(), pool=None, binding=None)
|
|
|
|
assert _binding_matches(result, "A", None, None) is True
|
|
|
|
|
|
def test_binding_matches_rejects_http_result_that_carries_ownership():
|
|
pool, binding = _pool_with_binding()
|
|
|
|
assert _binding_matches(ServerDiscoveryResult(tools=(), pool=pool, binding=None), "A", None, None) is False
|
|
assert _binding_matches(ServerDiscoveryResult(tools=(), pool=None, binding=binding), "A", None, None) is False
|
|
assert _binding_matches(ServerDiscoveryResult(tools=(), pool=pool, binding=binding), "A", None, None) is False
|
|
|
|
|
|
def test_binding_matches_accepts_current_stdio_binding():
|
|
pool, binding = _pool_with_binding(name="A", fingerprint="fp")
|
|
result = ServerDiscoveryResult(tools=(), pool=pool, binding=binding)
|
|
|
|
assert _binding_matches(result, "A", "fp", pool) is True
|
|
|
|
|
|
def test_binding_matches_rejects_foreign_pool():
|
|
pool, binding = _pool_with_binding(name="A", fingerprint="fp")
|
|
foreign = MCPSessionPool()
|
|
result = ServerDiscoveryResult(tools=(), pool=pool, binding=binding)
|
|
|
|
assert _binding_matches(result, "A", "fp", foreign) is False
|
|
assert _binding_matches(result, "A", "fp", None) is False
|
|
|
|
|
|
def test_binding_matches_rejects_mismatched_pool_and_binding_handles():
|
|
owner = MCPSessionPool()
|
|
binding = owner.ensure_binding("A", "fp", domain="deployment")
|
|
other_pool = MCPSessionPool()
|
|
# A result whose pool handle disagrees with the binding's owning pool must
|
|
# never be trusted, even when the *passed* pool still considers that exact
|
|
# binding current. This makes the ``result.pool is pool`` guard load-bearing.
|
|
result = ServerDiscoveryResult(tools=(), pool=other_pool, binding=binding)
|
|
|
|
assert _binding_matches(result, "A", "fp", owner) is False
|
|
|
|
|
|
def test_binding_matches_rejects_fingerprint_and_name_mismatch():
|
|
pool, binding = _pool_with_binding(name="A", fingerprint="fp")
|
|
result = ServerDiscoveryResult(tools=(), pool=pool, binding=binding)
|
|
|
|
assert _binding_matches(result, "A", "other-fp", pool) is False
|
|
assert _binding_matches(result, "B", "fp", pool) is False
|
|
|
|
|
|
def test_binding_matches_rejects_superseded_binding_object_with_same_fingerprint():
|
|
pool = MCPSessionPool()
|
|
stale = pool.ensure_binding("A", "fp", domain="deployment")
|
|
# A cross-worker delete/re-add of an identical server re-epochs the binding
|
|
# to a *new object* carrying the same fingerprint (the ABA case).
|
|
pool.reconcile_bindings({"A": "fp"}, [], domain="deployment", force_rebind=("A",))
|
|
current = pool._bindings[("deployment", "A")]
|
|
|
|
assert current is not stale
|
|
assert current.fingerprint == stale.fingerprint
|
|
|
|
# The stale object has identical field values but is no longer current, so
|
|
# value equality must never let it through.
|
|
assert _binding_matches(ServerDiscoveryResult(tools=(), pool=pool, binding=stale), "A", "fp", pool) is False
|
|
assert _binding_matches(ServerDiscoveryResult(tools=(), pool=pool, binding=current), "A", "fp", pool) is True
|
|
|
|
|
|
def test_binding_matches_rejects_retired_pool_binding():
|
|
pool, binding = _pool_with_binding(name="A", fingerprint="fp")
|
|
pool.retire_all()
|
|
result = ServerDiscoveryResult(tools=(), pool=pool, binding=binding)
|
|
|
|
assert _binding_matches(result, "A", "fp", pool) is False
|
|
|
|
|
|
def test_binding_matches_rejects_personal_domain_binding():
|
|
pool = MCPSessionPool()
|
|
personal = pool.ensure_binding("A", "fp", domain="personal")
|
|
result = ServerDiscoveryResult(tools=(), pool=pool, binding=personal)
|
|
|
|
# A deployment discovery may never reuse a same-named personal binding.
|
|
assert _binding_matches(result, "A", "fp", pool) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache entry scaffolding + repr secrecy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_server_tool_cache_starts_as_a_dict():
|
|
assert isinstance(cache_module._server_tool_cache, dict)
|
|
|
|
|
|
def test_server_cache_entry_repr_hides_snapshot_and_ownership_handles():
|
|
pool, binding = _pool_with_binding(name="A", fingerprint="secret-fingerprint")
|
|
entry = _ServerToolCacheEntry(
|
|
snapshot='{"env": {"TOKEN": "super-secret-token"}}',
|
|
interceptors="[]",
|
|
result=ServerDiscoveryResult(tools=(), pool=pool, binding=binding),
|
|
)
|
|
|
|
rendered = repr(entry)
|
|
|
|
assert "super-secret-token" not in rendered
|
|
assert "secret-fingerprint" not in rendered
|
|
assert "snapshot" not in rendered
|
|
|
|
|
|
def test_applied_revision_repr_hides_resolved_server_snapshots():
|
|
revision = _AppliedMcpRevision(
|
|
effective_snapshot='{"env": {"TOKEN": "super-secret-token"}}',
|
|
interceptors="[]",
|
|
stdio_connections={"A": "digest"},
|
|
lifecycle_tokens={},
|
|
server_snapshots={"A": '{"env": {"TOKEN": "super-secret-token"}}'},
|
|
)
|
|
|
|
rendered = repr(revision)
|
|
|
|
assert "super-secret-token" not in rendered
|
|
assert "server_snapshots" not in rendered
|
|
assert "effective_snapshot" not in rendered
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery reuse + atomic publication through ``initialize_mcp_tools``
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DISCOVERY_MISSING = object()
|
|
_DISCOVERY_TRACKED_GLOBALS = (
|
|
"_mcp_tools_cache",
|
|
"_cache_initialized",
|
|
"_config_path",
|
|
"_config_signature",
|
|
"_init_lock",
|
|
"_init_condition",
|
|
"_initializing_generation",
|
|
"_cache_generation",
|
|
"_mcp_config_snapshot",
|
|
"_initialized_without_config",
|
|
"_cache_reset_marker_signature",
|
|
"_applied_mcp_revision",
|
|
"_server_tool_cache",
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def discovery_cache(tmp_path, monkeypatch):
|
|
"""Isolate cache coordination state, ``_server_tool_cache`` and the pool singleton."""
|
|
saved = {name: getattr(cache_module, name, _DISCOVERY_MISSING) for name in _DISCOVERY_TRACKED_GLOBALS}
|
|
cache_module._mcp_tools_cache = None
|
|
cache_module._cache_initialized = False
|
|
cache_module._server_tool_cache = {}
|
|
for name in (
|
|
"_config_path",
|
|
"_config_signature",
|
|
"_mcp_config_snapshot",
|
|
"_initialized_without_config",
|
|
"_cache_reset_marker_signature",
|
|
"_applied_mcp_revision",
|
|
):
|
|
if hasattr(cache_module, name):
|
|
setattr(cache_module, name, None)
|
|
cache_module._init_lock = threading.RLock()
|
|
cache_module._init_condition = threading.Condition(cache_module._init_lock)
|
|
cache_module._initializing_generation = None
|
|
cache_module._cache_generation = 0
|
|
session_pool_module.reset_session_pool()
|
|
cfg = tmp_path / "extensions_config.json"
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
|
|
try:
|
|
yield cfg
|
|
finally:
|
|
session_pool_module.reset_session_pool()
|
|
for name, value in saved.items():
|
|
if value is _DISCOVERY_MISSING:
|
|
if hasattr(cache_module, name):
|
|
delattr(cache_module, name)
|
|
else:
|
|
setattr(cache_module, name, value)
|
|
|
|
|
|
def _stdio(name: str, command: str | None = None, **extra) -> dict:
|
|
return {"enabled": True, "type": "stdio", "command": command or f"cmd-{name}", **extra}
|
|
|
|
|
|
def _write_config(cfg, servers: dict, *, tokens: dict | None = None, interceptors: list[str] | str | None = None) -> None:
|
|
payload: dict = {"mcpServers": servers, "skills": {}}
|
|
if tokens is not None:
|
|
payload["mcpLifecycle"] = {"version": 1, "servers": tokens}
|
|
if interceptors is not None:
|
|
payload["mcpInterceptors"] = interceptors
|
|
cfg.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
class _DevTool:
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return f"<tool {self.name}>"
|
|
|
|
|
|
class _Discovery:
|
|
"""Deterministic grouped-discovery stand-in that records per-call selections."""
|
|
|
|
def __init__(self, *, empty=(), fail=()) -> None:
|
|
self.calls: list[tuple[str, ...]] = []
|
|
self.before_return: dict[str, object] = {}
|
|
self.empty = set(empty)
|
|
self.fail = set(fail)
|
|
|
|
async def __call__(self, *, extensions_config, server_names=None, session_pool=None, personal_user_id=None):
|
|
connections = build_servers_config(extensions_config)
|
|
selected = [name for name in connections if server_names is None or name in set(server_names)]
|
|
self.calls.append(tuple(selected))
|
|
bindings = {}
|
|
for name in selected:
|
|
connection = connections[name]
|
|
if connection.get("transport", "stdio") == "stdio":
|
|
bindings[name] = session_pool.ensure_binding(name, normalized_connection_fingerprint(connection), domain="deployment")
|
|
for name in selected:
|
|
hook = self.before_return.get(name)
|
|
if hook is not None:
|
|
await hook(session_pool)
|
|
groups = {}
|
|
for name in selected:
|
|
if name in self.fail:
|
|
continue
|
|
is_stdio = connections[name].get("transport", "stdio") == "stdio"
|
|
tools = () if name in self.empty else (_DevTool(name),)
|
|
groups[name] = ServerDiscoveryResult(tools=tools, pool=session_pool if is_stdio else None, binding=bindings.get(name))
|
|
return groups
|
|
|
|
|
|
def _install(monkeypatch, discovery: _Discovery) -> _Discovery:
|
|
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools_by_server", discovery)
|
|
return discovery
|
|
|
|
|
|
def _names(tools) -> list[str]:
|
|
return [getattr(tool, "name", None) for tool in tools]
|
|
|
|
|
|
def _fingerprint(servers: dict, name: str) -> str:
|
|
return normalized_connection_fingerprint(build_servers_config(ExtensionsConfig.model_validate({"mcpServers": servers}))[name])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unchanged_server_is_reused_by_identity(discovery_cache, monkeypatch):
|
|
"""Changing A re-discovers only A; B keeps its exact tool object and binding."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
|
|
tools1 = await cache_module.initialize_mcp_tools()
|
|
assert _names(tools1) == ["A", "B"]
|
|
assert discovery.calls == [("A", "B")]
|
|
tool_b = next(tool for tool in tools1 if tool.name == "B")
|
|
pool = session_pool_module.get_session_pool()
|
|
binding_b = pool._bindings[("deployment", "B")]
|
|
|
|
_write_config(cfg, {"A": _stdio("A", command="cmd-A2"), "B": _stdio("B")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools2 = await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B"), ("A",)]
|
|
assert _names(tools2) == ["A", "B"]
|
|
assert next(tool for tool in tools2 if tool.name == "B") is tool_b
|
|
assert pool._bindings[("deployment", "B")] is binding_b
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_only_change_rebuilds_only_that_server(discovery_cache, monkeypatch):
|
|
"""A presentation-only change rebuilds A but reuses B's entry."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
tools1 = await cache_module.initialize_mcp_tools()
|
|
tool_a = next(tool for tool in tools1 if tool.name == "A")
|
|
tool_b = next(tool for tool in tools1 if tool.name == "B")
|
|
|
|
_write_config(cfg, {"A": _stdio("A", description="renamed"), "B": _stdio("B")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools2 = await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B"), ("A",)]
|
|
assert next(tool for tool in tools2 if tool.name == "A") is not tool_a
|
|
assert next(tool for tool in tools2 if tool.name == "B") is tool_b
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifecycle_token_change_with_identical_connection_rebuilds_only_a(discovery_cache, monkeypatch):
|
|
"""A same-fingerprint lifecycle ABA still re-discovers A and reuses B."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")}, tokens={"A": "1" * 32, "B": "2" * 32})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
tools1 = await cache_module.initialize_mcp_tools()
|
|
tool_b = next(tool for tool in tools1 if tool.name == "B")
|
|
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")}, tokens={"A": "3" * 32, "B": "2" * 32})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools2 = await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B"), ("A",)]
|
|
assert next(tool for tool in tools2 if tool.name == "B") is tool_b
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declaration_reorder_rediscovers_nothing(discovery_cache, monkeypatch):
|
|
"""Reordering servers only re-flattens; zero discovery calls happen."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
tools1 = await cache_module.initialize_mcp_tools()
|
|
tool_a = next(tool for tool in tools1 if tool.name == "A")
|
|
tool_b = next(tool for tool in tools1 if tool.name == "B")
|
|
|
|
_write_config(cfg, {"B": _stdio("B"), "A": _stdio("A")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools2 = await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B")]
|
|
assert _names(tools2) == ["B", "A"]
|
|
assert tools2[0] is tool_b and tools2[1] is tool_a
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_discovery_publishes_siblings_without_caching_failure(discovery_cache, monkeypatch):
|
|
"""A failed server is absent (not a cached empty success); its sibling still publishes."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
discovery.fail.add("A")
|
|
_write_config(cfg, {"A": _stdio("A", command="cmd-A2"), "B": _stdio("B")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
|
|
assert _names(tools) == ["B"]
|
|
assert "A" not in cache_module._server_tool_cache
|
|
assert "B" in cache_module._server_tool_cache
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_tool_success_is_cached_and_not_rediscovered(discovery_cache, monkeypatch):
|
|
"""A successful zero-tool discovery is cached and reused (not re-requested)."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery(empty={"A"}))
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
assert cache_module._server_tool_cache["A"].result.tools == ()
|
|
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B", command="cmd-B2")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B"), ("B",)]
|
|
assert _names(tools) == ["B"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_candidate_superseded_by_generation_is_not_published(discovery_cache, monkeypatch):
|
|
"""A generation bump mid-discovery discards the whole candidate.
|
|
|
|
The trigger invalidates the claim without changing the config bytes or
|
|
retiring the pool, so the generation check is the only guard that can reject
|
|
the candidate (the post-read and binding checks would both still pass).
|
|
"""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
|
|
async def supersede(_pool):
|
|
with cache_module._init_condition:
|
|
cache_module._invalidate_published_tools_locked()
|
|
|
|
discovery.before_return["A"] = supersede
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
|
|
assert tools == []
|
|
assert cache_module._cache_initialized is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_binding_rotation_during_discovery_discards_candidate(discovery_cache, monkeypatch):
|
|
"""A rotated binding for a *reused* sibling fails the whole publication."""
|
|
cfg = discovery_cache
|
|
servers = {"A": _stdio("A"), "B": _stdio("B")}
|
|
_write_config(cfg, servers)
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
tool_b = cache_module._server_tool_cache["B"].result.tools[0]
|
|
|
|
_write_config(cfg, {"A": _stdio("A", command="cmd-A2"), "B": _stdio("B")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
|
|
async def rotate_b(pool):
|
|
pool.reconcile_bindings({"B": _fingerprint(servers, "B")}, [], domain="deployment", force_rebind=("B",))
|
|
|
|
discovery.before_return["A"] = rotate_b
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
|
|
assert tools == []
|
|
assert cache_module._cache_initialized is False
|
|
# The stale B entry must never be re-published.
|
|
assert cache_module._server_tool_cache["B"].result.tools[0] is tool_b
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_reset_forces_full_rediscovery(discovery_cache, monkeypatch):
|
|
"""An explicit reset clears entries so the next init discovers every server again."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
cache_module.reset_mcp_tools_cache()
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A", "B"), ("A", "B")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_initializer_releases_claim(discovery_cache, monkeypatch):
|
|
"""Cancelling mid-discovery releases the claim so the next initializer proceeds."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def gate(_pool):
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
discovery.before_return["A"] = gate
|
|
task = asyncio.create_task(cache_module.initialize_mcp_tools())
|
|
await asyncio.wait_for(entered.wait(), 2)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert cache_module._initializing_generation is None
|
|
assert cache_module._cache_initialized is False
|
|
|
|
discovery.before_return.pop("A")
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
assert _names(tools) == ["A"]
|
|
assert cache_module._cache_initialized is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_personal_binding_is_never_reused_as_deployment(discovery_cache, monkeypatch):
|
|
"""A same-named personal binding must not satisfy the deployment reuse check."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A")})
|
|
discovery = _install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
pool = session_pool_module.get_session_pool()
|
|
personal = pool.ensure_binding("A", "personal-fp", domain="personal")
|
|
binding_a = pool._bindings[("deployment", "A")]
|
|
|
|
_write_config(cfg, {"A": _stdio("A", command="cmd-A2")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
assert discovery.calls == [("A",), ("A",)]
|
|
assert pool._bindings[("personal", "A")] is personal
|
|
assert pool._bindings[("deployment", "A")] is not binding_a
|
|
|
|
|
|
def _structured_tool(name: str) -> StructuredTool:
|
|
async def _call() -> str:
|
|
return name
|
|
|
|
return StructuredTool.from_function(coroutine=_call, name=name, description=name)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unchanged_server_oauth_is_not_refetched(discovery_cache, monkeypatch):
|
|
"""A partial re-init only fetches OAuth headers for the re-discovered server."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
|
|
oauth_requests: list[set[str]] = []
|
|
|
|
async def fake_headers(extensions_config, *, token_manager=None, server_names=None):
|
|
oauth_requests.append(set(server_names or ()))
|
|
return {}
|
|
|
|
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", fake_headers)
|
|
monkeypatch.setattr("deerflow.mcp.tools.build_mcp_tool_interceptors", lambda *args, **kwargs: [])
|
|
|
|
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):
|
|
return [_structured_tool(server_name)]
|
|
|
|
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient)
|
|
|
|
await cache_module.initialize_mcp_tools()
|
|
assert oauth_requests[-1] == {"A", "B"}
|
|
|
|
_write_config(cfg, {"A": _stdio("A", command="cmd-A2"), "B": _stdio("B")})
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
tools = await cache_module.initialize_mcp_tools()
|
|
|
|
assert oauth_requests[-1] == {"A"} # B's OAuth headers are not refetched
|
|
assert sorted(tool.name for tool in tools) == ["A", "B"]
|
|
|
|
|
|
def _http(name: str, url: str | None = None) -> dict:
|
|
return {"enabled": True, "type": "http", "url": url or f"https://{name.lower()}.test/mcp"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"before,after,fail_once",
|
|
[
|
|
([], ["pkg.one:build"], False),
|
|
(["pkg.one:build"], [], False),
|
|
(["pkg.one:build"], ["pkg.two:build"], False),
|
|
(["pkg.one:build", "pkg.two:build"], ["pkg.two:build", "pkg.one:build"], False),
|
|
("pkg.one:build", ["pkg.one:build"], False),
|
|
([], ["pkg.one:build"], True),
|
|
],
|
|
ids=["add", "remove", "replace", "reorder", "equivalent-spelling", "failed-claim-retry"],
|
|
)
|
|
async def test_claim_time_interceptor_change_rewraps_unchanged_servers(discovery_cache, monkeypatch, before, after, fail_once):
|
|
"""A global change between reconciliation and the claim must reach every tool."""
|
|
from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
|
|
from mcp.types import CallToolResult, TextContent, Tool
|
|
|
|
cfg = discovery_cache
|
|
servers = {"A": _http("A"), "B": _http("B")}
|
|
_write_config(cfg, servers, interceptors=before)
|
|
discovered: list[str] = []
|
|
invoked: list[tuple[str, str]] = []
|
|
fail_discovery = False
|
|
|
|
class OfflineSession:
|
|
async def call_tool(self, name, arguments, **kwargs):
|
|
return CallToolResult(content=[TextContent(type="text", text="ok")])
|
|
|
|
class OfflineClient:
|
|
def __init__(self, connections, *, tool_interceptors, **kwargs):
|
|
self.tool_interceptors = tool_interceptors
|
|
|
|
async def get_tools(self, *, server_name):
|
|
discovered.append(server_name)
|
|
if fail_discovery:
|
|
raise RuntimeError("offline discovery failure")
|
|
return [
|
|
convert_mcp_tool_to_langchain_tool(
|
|
OfflineSession(),
|
|
Tool(name="probe", inputSchema={"type": "object", "properties": {}}),
|
|
server_name=server_name,
|
|
tool_interceptors=self.tool_interceptors,
|
|
tool_name_prefix=True,
|
|
)
|
|
]
|
|
|
|
def resolve_builder(path):
|
|
async def interceptor(request, handler):
|
|
invoked.append((request.server_name, path))
|
|
return await handler(request)
|
|
|
|
return lambda: interceptor
|
|
|
|
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", OfflineClient)
|
|
monkeypatch.setattr("deerflow.mcp.tools.resolve_variable", resolve_builder)
|
|
initial_tools = await cache_module.initialize_mcp_tools()
|
|
initial_b = next(tool for tool in initial_tools if tool.name == "B_probe")
|
|
|
|
servers["A"] = _http("A", url="https://a.test/changed")
|
|
_write_config(cfg, servers, interceptors=before)
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
assert "B" in cache_module._server_tool_cache
|
|
|
|
real_from_file = ExtensionsConfig.from_file
|
|
change_pending = True
|
|
|
|
def change_before_claim(*args, **kwargs):
|
|
nonlocal change_pending
|
|
if change_pending:
|
|
change_pending = False
|
|
_write_config(cfg, servers, interceptors=after)
|
|
return real_from_file(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(ExtensionsConfig, "from_file", change_before_claim)
|
|
if fail_once:
|
|
fail_discovery = True
|
|
with pytest.raises(RuntimeError, match="no results"):
|
|
await cache_module.initialize_mcp_tools()
|
|
assert cache_module._initializing_generation is None
|
|
fail_discovery = False
|
|
|
|
updated_tools = await cache_module.initialize_mcp_tools()
|
|
for tool in updated_tools:
|
|
await tool.ainvoke({})
|
|
assert invoked == [(name, path) for name in ("A", "B") for path in after]
|
|
|
|
repeated_tools = await asyncio.to_thread(cache_module.get_cached_mcp_tools)
|
|
assert all(current is repeated for current, repeated in zip(updated_tools, repeated_tools, strict=True))
|
|
invoked.clear()
|
|
for tool in repeated_tools:
|
|
await tool.ainvoke({})
|
|
assert invoked == [(name, path) for name in ("A", "B") for path in after]
|
|
|
|
changed = ([before] if isinstance(before, str) else before) != after
|
|
assert discovered == ["A", "B", *(["A", "B"] if fail_once else []), "A", *(["B"] if changed else [])]
|
|
updated_b = next(tool for tool in updated_tools if tool.name == "B_probe")
|
|
assert (updated_b is initial_b) is (not changed)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interceptor_change_without_baseline_drops_retained_entries(discovery_cache, monkeypatch):
|
|
"""With no applied baseline, a retained HTTP entry must not outlive an interceptor change."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _http("A")})
|
|
_install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
assert "A" in cache_module._server_tool_cache
|
|
|
|
# Conservative state: entries retained but the comparing baseline is gone.
|
|
cache_module._applied_mcp_revision = None
|
|
cache_module._cache_initialized = False
|
|
|
|
cfg.write_text(json.dumps({"mcpServers": {"A": _http("A")}, "skills": {}, "mcpInterceptors": ["pkg.mod:build"]}), encoding="utf-8")
|
|
assert cache_module.refresh_mcp_cache_if_active() is True
|
|
|
|
assert cache_module._server_tool_cache == {}
|
|
|
|
|
|
def test_shared_reset_counts_retained_entries(discovery_cache):
|
|
"""A retained per-server entry must make a shared reset consider local state."""
|
|
pool = session_pool_module.get_session_pool()
|
|
assert cache_module._shared_reset_has_local_state_locked(pool) is False
|
|
|
|
cache_module._server_tool_cache["A"] = cache_module._ServerToolCacheEntry(
|
|
snapshot="orphan",
|
|
interceptors="[]",
|
|
result=ServerDiscoveryResult(tools=()),
|
|
)
|
|
|
|
assert cache_module._shared_reset_has_local_state_locked(pool) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_total_discovery_failure_does_not_publish_empty_success(discovery_cache, monkeypatch):
|
|
"""Every enabled server failing is a phase-level failure, not a cached empty success."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A"), "B": _stdio("B")})
|
|
_install(monkeypatch, _Discovery(fail={"A", "B"}))
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
assert cache_module._cache_initialized is False
|
|
assert cache_module._initializing_generation is None
|
|
assert cache_module._server_tool_cache == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_failure_after_claim_releases_generation(discovery_cache, monkeypatch):
|
|
"""A lazy-import failure must not strand the initialization claim."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _stdio("A")})
|
|
real_import = builtins.__import__
|
|
|
|
def failing_import(name, *args, **kwargs):
|
|
if name == "deerflow.mcp.tools":
|
|
raise ImportError("simulated missing adapter module")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", failing_import)
|
|
|
|
with pytest.raises(ImportError):
|
|
await cache_module.initialize_mcp_tools()
|
|
|
|
assert cache_module._initializing_generation is None
|
|
|
|
|
|
def test_lazy_init_failure_does_not_log_resolved_credentials(discovery_cache, monkeypatch, caplog):
|
|
"""A stage-level discovery failure must not print a resolved-credential message."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _http("A")})
|
|
secret = "s3cr3t-pass-123"
|
|
|
|
async def boom(*args, **kwargs):
|
|
raise RuntimeError(f"discovery failed for url 'https://alice:{secret}@remote.test/mcp'")
|
|
|
|
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools_by_server", boom)
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
assert cache_module.get_cached_mcp_tools() == []
|
|
|
|
assert secret not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_committed_interceptor_change_without_baseline_drops_retained_entries(discovery_cache, monkeypatch):
|
|
"""The committed-writer path mirrors the reader's no-baseline interceptor guard."""
|
|
cfg = discovery_cache
|
|
_write_config(cfg, {"A": _http("A")})
|
|
_install(monkeypatch, _Discovery())
|
|
await cache_module.initialize_mcp_tools()
|
|
assert "A" in cache_module._server_tool_cache
|
|
|
|
cache_module._applied_mcp_revision = None
|
|
cache_module._cache_initialized = False
|
|
|
|
committed = ExtensionsConfig.model_validate({"mcpServers": {"A": _http("A")}, "mcpInterceptors": ["pkg.mod:build"]})
|
|
cache_module.finish_mcp_reconciliation(cache_module.prepare_mcp_reconciliation(committed, config_path=cfg))
|
|
|
|
assert cache_module._server_tool_cache == {}
|