deer-flow/backend/tests/test_mcp_lifecycle.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

162 lines
5.8 KiB
Python

"""Unit coverage for the shared ``mcpLifecycle`` generation ledger."""
from __future__ import annotations
import pytest
from deerflow.config.extensions_config import validate_raw_extensions_config
from deerflow.mcp.lifecycle import (
LIFECYCLE_KEY,
InvalidMcpLifecycle,
_stdio_fingerprints,
effective_tokens,
legacy_token,
lifecycle_delta,
new_token,
parse_mcp_lifecycle,
plan_mcp_lifecycle,
)
def _stdio(command: str, **extra) -> dict:
return {"enabled": True, "type": "stdio", "command": command, **extra}
def _config(servers: dict) -> dict:
return {"mcpServers": servers, "skills": {}}
def _ledger(**servers: str) -> dict:
return {LIFECYCLE_KEY: {"version": 1, "servers": dict(servers)}}
def _fingerprint(config: dict, name: str) -> str:
return _stdio_fingerprints(validate_raw_extensions_config(config))[name]
# ---------------------------------------------------------------------------
# Strict parsing (tri-state)
# ---------------------------------------------------------------------------
def test_absent_ledger_is_legacy_none():
assert parse_mcp_lifecycle({"mcpServers": {}}) is None
assert parse_mcp_lifecycle(None) is None
def test_valid_ledger_parses_with_servers():
ledger = parse_mcp_lifecycle(_ledger(A="a" * 32, B="b" * 32))
assert ledger is not None
assert ledger.version == 1
assert ledger.servers == {"A": "a" * 32, "B": "b" * 32}
@pytest.mark.parametrize(
"block",
[
{"version": True, "servers": {}},
{"version": 2, "servers": {}},
{"version": "1", "servers": {}},
{"version": 1.0, "servers": {}},
{"version": 1, "servers": []},
{"version": 1, "servers": {"A": "A" * 32}}, # uppercase
{"version": 1, "servers": {"A": "abc"}}, # too short
{"version": 1, "servers": {"A": "a" * 31}}, # 31 chars
{"version": 1, "servers": {"A": "a" * 33}}, # 33 chars
{"version": 1, "servers": {"A": "a" * 32 + "\n"}}, # trailing newline
{"version": 1, "servers": {"A": "\n" + "a" * 32}}, # leading newline
{"version": 1, "servers": {"A": "a" * 32 + " "}}, # trailing space
{"version": 1, "servers": {"A": 1}}, # wrong type
{"version": 1, "servers": {"": "a" * 32}}, # empty name
{"version": 1, "servers": {}, "extra": True},
],
)
def test_invalid_ledger_fails_closed(block):
with pytest.raises(InvalidMcpLifecycle):
parse_mcp_lifecycle({LIFECYCLE_KEY: block})
# ---------------------------------------------------------------------------
# Legacy derivation and planner
# ---------------------------------------------------------------------------
def test_legacy_token_is_deterministic_and_name_fingerprint_sensitive():
fp = "f" * 64
assert legacy_token("A", fp) == legacy_token("A", fp)
assert legacy_token("A", fp) != legacy_token("B", fp)
assert legacy_token("A", fp) != legacy_token("A", "e" * 64)
assert len(legacy_token("A", fp)) == 64
def test_unchanged_legacy_servers_are_not_retired():
previous = _config({"A": _stdio("cmd-A1"), "B": _stdio("cmd-B1")})
candidate = _config({"A": _stdio("cmd-A2"), "B": _stdio("cmd-B1")})
plan = plan_mcp_lifecycle(previous, candidate)
assert plan["version"] == 1
# Only A changed; B stays a derived legacy token (no explicit entry).
assert set(plan["servers"]) == {"A"}
tokens = effective_tokens(validate_raw_extensions_config(candidate), parse_mcp_lifecycle(plan))
assert tokens["B"] == legacy_token("B", _fingerprint(candidate, "B"))
def test_delete_and_identical_readd_each_advance_the_generation():
original = _config({"A": _stdio("cmd-A1")})
first = plan_mcp_lifecycle(None, original)["servers"]["A"]
deleted = _config({})
after_delete = plan_mcp_lifecycle(original, deleted)["servers"]["A"]
assert after_delete != first
re_added = plan_mcp_lifecycle(deleted, original)["servers"]["A"]
assert re_added not in {first, after_delete}
def test_metadata_only_change_does_not_advance():
previous = _config({"A": _stdio("cmd-A1")})
candidate = _config({"A": _stdio("cmd-A1", description="renamed", tool_name_prefix=False)})
assert plan_mcp_lifecycle(previous, candidate)["servers"] == {}
def test_schema_invalid_previous_config_is_a_conservative_migration():
# Raw JSON is valid, but the server map has the wrong shape.
previous = {"mcpServers": "not-a-dict", "skills": {}}
candidate = _config({"A": _stdio("cmd-A1"), "B": _stdio("cmd-B1")})
plan = plan_mcp_lifecycle(previous, candidate)
assert set(plan["servers"]) == {"A", "B"}
assert all(token != "" for token in plan["servers"].values())
def test_effective_tokens_keeps_tombstones():
candidate = _config({"A": _stdio("cmd-A1")})
ledger = parse_mcp_lifecycle(_ledger(A="a" * 32, B="b" * 32))
tokens = effective_tokens(validate_raw_extensions_config(candidate), ledger)
assert tokens["A"] == "a" * 32 # explicit ledger token wins
assert tokens["B"] == "b" * 32 # tombstone retained
def test_lifecycle_delta_reports_only_changed_active_servers():
delta = lifecycle_delta(
previous_tokens={"A": "1" * 32, "B": "2" * 32},
candidate_tokens={"A": "3" * 32, "B": "2" * 32, "C": "4" * 32},
active_names={"A", "B"},
)
assert delta == frozenset({"A"})
def test_new_token_is_strict_hex():
token = new_token()
assert len(token) == 32
assert token == token.lower()
assert all(ch in "0123456789abcdef" for ch in token)
def test_token_rejects_trailing_newline():
"""``$`` would accept a trailing newline; the validator must use fullmatch."""
with pytest.raises(InvalidMcpLifecycle):
parse_mcp_lifecycle({LIFECYCLE_KEY: {"version": 1, "servers": {"A": "a" * 32 + "\n"}}})
assert parse_mcp_lifecycle({LIFECYCLE_KEY: {"version": 1, "servers": {"A": "a" * 32}}}) is not None