deer-flow/backend/tests/test_mcp_lifecycle_invalid.py
spud 75ce6f4920
fix(mcp): preserve server binding history across gateway workers (#6617)
* fix(mcp): preserve server binding history across gateway workers

A peer Gateway worker could keep a stale pooled session after another worker deleted and identically re-added a stdio server, because the final effective MCP slice matched the revision it had already applied.

Record a per-server lifecycle generation in the shared extensions config and consume it in the reader, discovery claim, and committed handoff so an observed generation change force-rebinds only the affected server. Legacy configs derive a stable in-memory token; deletions leave permanent tombstones.

* fix(mcp): fail closed on an invalid mcpLifecycle instead of retrying

An unverifiable mcpLifecycle made initialize_mcp_tools() unpublishable, so get_cached_mcp_tools() retried discovery forever and could keep respawning stdio servers. Abort discovery on an invalid ledger after conservatively retiring local MCP state; a fixed config recovers on the next call.

Also documents the rolling-upgrade prerequisite: cross-worker ABA detection requires configuration writes to go through a lifecycle-aware writer.

* refactor(mcp): share stdio fingerprinting across lifecycle paths

Reuse lifecycle._stdio_fingerprints from the applied-revision builder so the connection-identity rule has a single implementation, and move the unusable-server diagnostic into the shared helper.

Add a regression proving the Gateway Skills writer preserves mcpLifecycle byte-for-value, and document that DeerFlowClient.update_mcp_config can raise RuntimeError after the config was committed.

* docs(mcp): condense lifecycle guidance under the AGENTS chain limit

The effective mcp/AGENTS.md chain was 15 bytes under the 98,304-byte CI hard limit, so the standalone cross-worker lifecycle bullet pushed it over. Fold the mcpLifecycle and rolling-upgrade facts into the existing selective-reconciliation bullet and condense it, keeping the chain under the limit while preserving the contract.

* docs: trim inherited agent guidance below CI size limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-11 11:45:28 +08:00

139 lines
4.4 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}
async def _fake_get_mcp_tools(**_kwargs):
calls["n"] += 1
return ["should-not-publish"]
tools_module.get_mcp_tools = _fake_get_mcp_tools
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}
async def _fake_get_mcp_tools(**_kwargs):
calls["n"] += 1
return ["tool"]
tools_module.get_mcp_tools = _fake_get_mcp_tools
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