deer-flow/backend/tests/test_mcp_cache_tool_assembly.py
spud 8204dcbb20
fix(mcp): invalidate cache only for effective MCP config changes (#5750)
* fix(mcp): avoid cache resets for unrelated config changes

extensions_config.json carries MCP servers, skills and middleware settings, but the MCP cache treated any whole-file signature change as MCP state change, so toggling a skill retired every cached tool and closed all pooled sessions.

Record a snapshot of the effective MCP slice (enabled servers' parsed config plus mcpInterceptors) and retire MCP state only when that slice changes. Hand the exact config revision to tool discovery so a change during discovery cannot publish tools built from a superseded revision, and add a non-initializing refresh entry point so disabling the last enabled server still converges the cache and pool on the next tool assembly.

* docs(mcp): align cache guide and restore error diagnostics

Review feedback on #5750 flagged three non-blocking issues: the MCP module guide still described the pre-semantic-snapshot invalidation contract; the tools.py sanitization covered every exception in the MCP block, discarding diagnostics for failures that cannot carry resolved credentials; and _is_cache_stale()'s docstring presented it as a pure predicate despite adopting a new file signature.

Narrow the type-only logging to ExtensionsConfig.from_file() and restore logger.exception() for tool caching, pool cleanup and plugin filtering, with a regression test asserting the message and traceback survive. Document the effective-MCP-slice comparison, the stable config handoff to discovery, refresh_mcp_cache_if_active() and the _initialized_without_config handling in the module guide, and note the signature-adoption side effect in the docstring.

* fix(mcp): unify config normalization and verify signatures

Review follow-up on #5750.

- Normalize mcpInterceptors (bare string == single-element list; missing key == empty list) and the type/transport alias through one shared module used by the tools cache, the interceptor builder and the durable-task config snapshot, so equivalent spellings no longer retire tools/sessions or raise McpTaskConfigurationError.
- Treat a resolved-path switch as a signal to re-read the effective MCP slice instead of an invalidation reason: an identical slice adopts the new path and signature and keeps pooled sessions.
- Refuse to publish or adopt a signature whose sha256 digest is missing ((mtime, size, None) from a file that could not be read).
- Sync the English and Chinese MCP user docs with the effective-config comparison, and document that the Gateway admin reset stays unconditional.

* docs(mcp): note the unverifiable-signature fail-closed branch

A present config file whose content cannot be hashed is treated as stale, unlike a deleted file which keeps the last-known-good cache. Record that asymmetry in the module guide.

* docs(mcp): scope the no-hash rationale to never-initialized processes

The cost-free early return in refresh_mcp_cache_if_active() only covers a process that never published an MCP cache. A process that did publish one (including an empty cache from the zero-server or LangGraph Studio lazy-init path) still pays one stat+sha256 per tool assembly. Correct the rationale in the docstring and the matching comment in get_available_tools().
2026-09-24 17:19:01 +08:00

112 lines
4.0 KiB
Python

"""Regressions for MCP cache handling in ``get_available_tools()``."""
import json
import logging
from types import SimpleNamespace
from deerflow.tools.tools import get_available_tools
def _make_config():
return SimpleNamespace(
tools=[
SimpleNamespace(name="bash", group="bash", use="deerflow.sandbox.tools:bash_tool"),
SimpleNamespace(name="ls", group="file:read", use="tests:ls_tool"),
],
models=[],
sandbox=SimpleNamespace(use="deerflow.sandbox.local:LocalSandboxProvider", allow_host_bash=False),
tool_search=SimpleNamespace(enabled=False),
get_model_config=lambda name: None,
)
def test_tool_assembly_refreshes_cache_when_no_server_is_enabled(monkeypatch):
from deerflow.config.extensions_config import ExtensionsConfig
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config())
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"),
)
monkeypatch.setattr(
ExtensionsConfig,
"from_file",
classmethod(lambda cls, config_path=None: ExtensionsConfig(mcp_servers={}, skills={})),
)
calls: list[str] = []
monkeypatch.setattr(
"deerflow.mcp.cache.refresh_mcp_cache_if_active",
lambda: calls.append("refresh") or False,
)
get_available_tools(include_mcp=True, subagent_enabled=False)
assert calls == ["refresh"]
def test_tool_assembly_does_not_leak_credentials_on_malformed_config(monkeypatch, tmp_path, caplog):
"""A malformed config must not echo resolved $VAR secrets into the log."""
cfg = tmp_path / "extensions_config.json"
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
monkeypatch.setenv("MCP_SECRET", "TOPSECRET123")
cfg.write_text(
json.dumps(
{
"mcpServers": {
"srv1": {"enabled": True, "type": "stdio", "command": "npx", "headers": "$MCP_SECRET"},
},
"skills": {},
}
),
encoding="utf-8",
)
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config())
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"),
)
with caplog.at_level(logging.WARNING):
get_available_tools(include_mcp=True, subagent_enabled=False)
assert "TOPSECRET123" not in caplog.text
mcp_records = [record for record in caplog.records if "MCP" in record.getMessage()]
assert any("Failed to load MCP extensions config" in record.getMessage() for record in mcp_records)
assert all(record.exc_info is None for record in mcp_records)
def test_tool_assembly_logs_diagnostics_for_non_config_failures(monkeypatch, caplog):
"""Only config-load failures are sanitized; other MCP errors keep diagnostics."""
from deerflow.config.extensions_config import ExtensionsConfig
monkeypatch.setattr("deerflow.tools.tools.get_app_config", lambda: _make_config())
monkeypatch.setattr(
"deerflow.tools.tools.resolve_variable",
lambda use, _: SimpleNamespace(name="bash" if "bash" in use else "ls"),
)
monkeypatch.setattr(
ExtensionsConfig,
"from_file",
classmethod(
lambda cls, config_path=None: ExtensionsConfig.model_validate(
{
"mcpServers": {"srv1": {"enabled": True, "type": "stdio", "command": "npx"}},
"skills": {},
}
)
),
)
def _boom():
raise RuntimeError("retired pool close failed: pipe still open")
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", _boom)
with caplog.at_level(logging.ERROR):
get_available_tools(include_mcp=True, subagent_enabled=False)
assert "retired pool close failed: pipe still open" in caplog.text
assert any(record.exc_info is not None for record in caplog.records)