deer-flow/backend/tests/test_mcp_lifecycle_invalid.py
spud eadc1e230e
feat(mcp): reuse unchanged per-server tool discovery results (#6795)
* 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>
2026-10-14 08:44:21 +08:00

159 lines
5.8 KiB
Python

"""Invalid ``mcpLifecycle`` must abort lazy MCP initialization deterministically.
An unverifiable ledger can never be published, so a retry loop that keeps
re-running discovery is both a hang and a resource leak. These tests drive the
real ``get_cached_mcp_tools()`` entry in a subprocess (with a timeout) so a
regression cannot hang the pytest worker.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
_CONFIG = {"mcpServers": {"A": {"enabled": True, "type": "stdio", "command": "npx"}}, "skills": {}}
# Aborts on an unverifiable ledger before discovery: the fake discovery counter
# must stay at 0, and a pre-existing durable binding must be retired.
_INVALID_PROGRAM = textwrap.dedent(
"""
import json
import os
from pathlib import Path
import deerflow.mcp.tools as tools_module
from deerflow.mcp import cache as cache_module
from deerflow.mcp import session_pool as session_pool_module
cfg = Path(os.environ["DEER_FLOW_EXTENSIONS_CONFIG_PATH"])
pool = session_pool_module.get_session_pool()
pool.ensure_binding("A", "durable-fp", domain="deployment")
calls = {"n": 0}
from deerflow.mcp.client import build_servers_config
from deerflow.mcp.session_pool import normalized_connection_fingerprint
from deerflow.mcp.tools import ServerDiscoveryResult
async def _fake_by_server(*, extensions_config, server_names=None, session_pool=None, personal_user_id=None):
calls["n"] += 1
groups = {}
for name, connection in build_servers_config(extensions_config).items():
binding = None
if connection.get("transport", "stdio") == "stdio":
binding = session_pool.ensure_binding(name, normalized_connection_fingerprint(connection), domain="deployment")
groups[name] = ServerDiscoveryResult(tools=("should-not-publish",), pool=session_pool if binding is not None else None, binding=binding)
return groups
tools_module.get_mcp_tools_by_server = _fake_by_server
result = cache_module.get_cached_mcp_tools()
print(json.dumps({
"result": len(result),
"discovery_calls": calls["n"],
"old_pool_retired": pool._retired,
"pool_singleton_is_none": session_pool_module._pool is None,
}))
"""
)
# The same call must recover once the ledger is fixed, without a restart.
_RECOVERY_PROGRAM = textwrap.dedent(
"""
import json
import os
from pathlib import Path
import deerflow.mcp.tools as tools_module
from deerflow.mcp import cache as cache_module
cfg = Path(os.environ["DEER_FLOW_EXTENSIONS_CONFIG_PATH"])
calls = {"n": 0}
from deerflow.mcp.client import build_servers_config
from deerflow.mcp.session_pool import normalized_connection_fingerprint
from deerflow.mcp.tools import ServerDiscoveryResult
async def _fake_by_server(*, extensions_config, server_names=None, session_pool=None, personal_user_id=None):
calls["n"] += 1
groups = {}
for name, connection in build_servers_config(extensions_config).items():
binding = None
if connection.get("transport", "stdio") == "stdio":
binding = session_pool.ensure_binding(name, normalized_connection_fingerprint(connection), domain="deployment")
groups[name] = ServerDiscoveryResult(tools=("tool",), pool=session_pool if binding is not None else None, binding=binding)
return groups
tools_module.get_mcp_tools_by_server = _fake_by_server
first = cache_module.get_cached_mcp_tools()
first_calls = calls["n"]
cfg.write_text(os.environ["DEER_FLOW_VALID_CONFIG"], encoding="utf-8")
second = cache_module.get_cached_mcp_tools()
print(json.dumps({
"first": len(first),
"first_calls": first_calls,
"second": len(second),
"total_calls": calls["n"],
}))
"""
)
def _run_program(program: str, cfg: Path) -> dict:
env = os.environ.copy()
env["DEER_FLOW_EXTENSIONS_CONFIG_PATH"] = str(cfg)
env["DEER_FLOW_VALID_CONFIG"] = json.dumps(_CONFIG)
try:
completed = subprocess.run(
[sys.executable, "-c", program],
cwd=str(Path(__file__).resolve().parent.parent),
env=env,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
pytest.fail("get_cached_mcp_tools() did not terminate on an invalid mcpLifecycle")
assert completed.returncode == 0, f"worker failed ({completed.returncode})\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
return json.loads(completed.stdout.strip().splitlines()[-1])
@pytest.mark.parametrize(
"ledger",
[
{"version": 2, "servers": {}},
{"version": 1, "servers": {"A": "a" * 32 + "\n"}},
],
)
def test_invalid_lifecycle_aborts_lazy_initialization_without_discovery(ledger, tmp_path):
cfg = tmp_path / "extensions_config.json"
cfg.write_text(json.dumps({**_CONFIG, "mcpLifecycle": ledger}), encoding="utf-8")
payload = _run_program(_INVALID_PROGRAM, cfg)
assert payload["result"] == 0
assert payload["discovery_calls"] == 0 # discovery is never entered
assert payload["old_pool_retired"] is True # stale bindings are not reused
assert payload["pool_singleton_is_none"] is True
def test_invalid_lifecycle_recovers_after_the_config_is_fixed(tmp_path):
cfg = tmp_path / "extensions_config.json"
cfg.write_text(json.dumps({**_CONFIG, "mcpLifecycle": {"version": 2, "servers": {}}}), encoding="utf-8")
payload = _run_program(_RECOVERY_PROGRAM, cfg)
assert payload["first"] == 0
assert payload["first_calls"] == 0
assert payload["second"] == 1 # valid config initializes normally
assert payload["total_calls"] == 1