mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +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().
174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.mcp.tasks.runtime import (
|
|
McpTaskConfigurationError,
|
|
set_mcp_task_config_snapshot,
|
|
validate_mcp_task_config_snapshot,
|
|
validate_mcp_task_runtime_configuration,
|
|
)
|
|
|
|
|
|
def _extensions() -> ExtensionsConfig:
|
|
return ExtensionsConfig.model_validate(
|
|
{
|
|
"mcpServers": {
|
|
"reports": {
|
|
"task_toolsets": [
|
|
{
|
|
"name": "reports",
|
|
"submit_tool": "submit_report",
|
|
"status_tool": "status_report",
|
|
"cancel_tool": "cancel_report",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_configured_task_toolsets_require_enabled_runtime() -> None:
|
|
with pytest.raises(McpTaskConfigurationError, match="mcp_tasks.enabled=true"):
|
|
validate_mcp_task_runtime_configuration(
|
|
mcp_tasks_config=SimpleNamespace(enabled=False),
|
|
extensions_config=_extensions(),
|
|
repository_available=True,
|
|
)
|
|
|
|
|
|
def test_configured_task_toolsets_require_sql_persistence() -> None:
|
|
with pytest.raises(McpTaskConfigurationError, match="database.backend"):
|
|
validate_mcp_task_runtime_configuration(
|
|
mcp_tasks_config=SimpleNamespace(enabled=True),
|
|
extensions_config=_extensions(),
|
|
repository_available=False,
|
|
)
|
|
|
|
|
|
def test_no_task_toolsets_leave_existing_mcp_runtime_unchanged() -> None:
|
|
validate_mcp_task_runtime_configuration(
|
|
mcp_tasks_config=SimpleNamespace(enabled=False),
|
|
extensions_config=ExtensionsConfig(),
|
|
repository_available=False,
|
|
)
|
|
|
|
|
|
def test_task_toolset_server_transport_is_validated_at_startup() -> None:
|
|
extensions = _extensions()
|
|
extensions.mcp_servers["reports"].command = None
|
|
|
|
with pytest.raises(McpTaskConfigurationError, match="requires 'command'"):
|
|
validate_mcp_task_runtime_configuration(
|
|
mcp_tasks_config=SimpleNamespace(enabled=True),
|
|
extensions_config=extensions,
|
|
repository_available=True,
|
|
)
|
|
|
|
|
|
def test_task_enabled_server_changes_require_gateway_restart() -> None:
|
|
startup = _extensions()
|
|
current = _extensions()
|
|
current.mcp_servers["reports"].env["TOKEN"] = "rotated"
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
with pytest.raises(McpTaskConfigurationError, match="reports.*restart"):
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def test_unrelated_extension_changes_do_not_invalidate_task_runtime_snapshot() -> None:
|
|
startup = _extensions()
|
|
current = ExtensionsConfig.model_validate(
|
|
{
|
|
**startup.model_dump(by_alias=True),
|
|
"skills": {"writer": {"enabled": False}},
|
|
"mcpServers": {
|
|
**startup.model_dump(by_alias=True)["mcpServers"],
|
|
"search": {"command": "search-mcp"},
|
|
},
|
|
}
|
|
)
|
|
current.mcp_servers["reports"].description = "Updated Agent-facing description"
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def test_disabled_task_server_changes_do_not_invalidate_task_runtime_snapshot() -> None:
|
|
startup = _extensions()
|
|
startup.mcp_servers["reports"].enabled = False
|
|
current = _extensions()
|
|
current.mcp_servers["reports"].enabled = False
|
|
current.mcp_servers["reports"].env["TOKEN"] = "rotated"
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def test_mcp_interceptor_changes_require_gateway_restart_for_task_tools() -> None:
|
|
startup = _extensions()
|
|
current = ExtensionsConfig.model_validate(
|
|
{
|
|
**startup.model_dump(by_alias=True),
|
|
"mcpInterceptors": ["example.interceptor:build"],
|
|
}
|
|
)
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
with pytest.raises(McpTaskConfigurationError, match="mcpInterceptors.*restart"):
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def test_mcp_interceptor_changes_remain_hot_reloadable_without_task_tools() -> None:
|
|
startup = ExtensionsConfig()
|
|
current = ExtensionsConfig.model_validate({"mcpInterceptors": ["example.interceptor:build"]})
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def _task_server(task_toolsets: list[dict]) -> dict:
|
|
return {"mcpServers": {"reports": {"command": "reports-mcp", "task_toolsets": task_toolsets}}}
|
|
|
|
|
|
_TASK_TOOLSETS = [
|
|
{
|
|
"name": "reports",
|
|
"submit_tool": "submit_report",
|
|
"status_tool": "status_report",
|
|
"cancel_tool": "cancel_report",
|
|
}
|
|
]
|
|
|
|
|
|
def test_equivalent_interceptor_spellings_do_not_invalidate_task_runtime_snapshot() -> None:
|
|
startup = ExtensionsConfig.model_validate({**_task_server(_TASK_TOOLSETS), "mcpInterceptors": "example.interceptor:build"})
|
|
current = ExtensionsConfig.model_validate({**_task_server(_TASK_TOOLSETS), "mcpInterceptors": ["example.interceptor:build"]})
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
def test_transport_alias_does_not_invalidate_task_runtime_snapshot() -> None:
|
|
startup = ExtensionsConfig.model_validate({"mcpServers": {"reports": {"type": "stdio", "command": "reports-mcp", "task_toolsets": _TASK_TOOLSETS}}})
|
|
current = ExtensionsConfig.model_validate({"mcpServers": {"reports": {"transport": "stdio", "command": "reports-mcp", "task_toolsets": _TASK_TOOLSETS}}})
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
validate_mcp_task_config_snapshot(current)
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|