mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
* 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().
112 lines
4.0 KiB
Python
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)
|