mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +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>
159 lines
5.8 KiB
Python
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
|