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

126 lines
5.0 KiB
Python

"""Process-local bridge from Agent tool wrappers to the Gateway task service."""
from __future__ import annotations
from typing import Any, Protocol
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.config_normalization import normalize_mcp_interceptor_paths, normalize_mcp_server_config
from deerflow.mcp.tasks.models import TaskSubmitRequest
class McpTaskConfigurationError(RuntimeError):
"""The configured long-running MCP contract cannot run safely."""
class McpTaskSubmitter(Protocol):
async def submit(
self,
*,
driver_name: str,
request: TaskSubmitRequest,
now: Any | None = None,
) -> dict: ...
async def list_tasks(
self,
*,
thread_id: str,
user_id: str,
thread_incarnation: str | None,
limit: int = 50,
active_only: bool = False,
) -> list[dict[str, Any]]: ...
async def cancel_matching_task(
self,
*,
thread_id: str,
user_id: str,
thread_incarnation: str | None,
task: str | None = None,
) -> dict[str, Any]: ...
_submitter: McpTaskSubmitter | None = None
_TaskServerConfigSnapshot = tuple[dict[str, dict[str, Any]], Any]
_task_server_config_snapshot: _TaskServerConfigSnapshot | None = None
def _task_server_configs(extensions_config: ExtensionsConfig) -> _TaskServerConfigSnapshot:
servers: dict[str, dict[str, Any]] = {}
for server_name, server in extensions_config.get_enabled_mcp_servers().items():
if not server.task_toolsets:
continue
servers[server_name] = normalize_mcp_server_config(server, task_runtime=True)
raw_interceptors = (extensions_config.model_extra or {}).get("mcpInterceptors")
interceptors = normalize_mcp_interceptor_paths(raw_interceptors) if servers else None
return servers, interceptors
def set_mcp_task_config_snapshot(extensions_config: ExtensionsConfig | None) -> None:
"""Freeze task-enabled server settings for one Gateway process lifetime."""
global _task_server_config_snapshot
_task_server_config_snapshot = None if extensions_config is None else _task_server_configs(extensions_config)
def validate_mcp_task_config_snapshot(extensions_config: ExtensionsConfig) -> None:
"""Reject hot changes that would split tool discovery from background calls."""
if _task_server_config_snapshot is None:
return
current = _task_server_configs(extensions_config)
if current == _task_server_config_snapshot:
return
current_servers, current_interceptors = current
startup_servers, startup_interceptors = _task_server_config_snapshot
changed = sorted(server_name for server_name in current_servers.keys() | startup_servers.keys() if current_servers.get(server_name) != startup_servers.get(server_name))
if current_interceptors != startup_interceptors:
changed.append("mcpInterceptors")
names = ", ".join(changed) or "<unknown>"
raise McpTaskConfigurationError(f"MCP task-enabled server configuration changed after Gateway startup ({names}); restart DeerFlow before using durable task tools")
def set_mcp_task_submitter(submitter: McpTaskSubmitter | None) -> None:
"""Install or clear the Gateway-owned submit boundary for this process."""
global _submitter
_submitter = submitter
def is_mcp_task_runtime_available() -> bool:
"""Return whether the Gateway-owned durable task runtime is installed."""
return _submitter is not None
def get_mcp_task_submitter() -> McpTaskSubmitter:
if _submitter is None:
raise McpTaskConfigurationError("The MCP task runtime is not initialized. Run this tool through the Gateway with mcp_tasks.enabled=true and a SQL database backend.")
return _submitter
def configured_task_toolset_count(extensions_config: ExtensionsConfig) -> int:
return sum(len(server.task_toolsets) for server in extensions_config.get_enabled_mcp_servers().values())
def validate_mcp_task_runtime_configuration(
*,
mcp_tasks_config: Any,
extensions_config: ExtensionsConfig,
repository_available: bool,
) -> None:
"""Fail startup when task toolsets would silently fall back to sync calls."""
if configured_task_toolset_count(extensions_config) == 0:
return
if not bool(getattr(mcp_tasks_config, "enabled", False)):
raise McpTaskConfigurationError("MCP task_toolsets are configured, so mcp_tasks.enabled=true is required; DeerFlow will not silently expose these tools as synchronous calls.")
if not repository_available:
raise McpTaskConfigurationError("MCP task_toolsets require durable SQL persistence. Set database.backend to 'sqlite' or 'postgres'; the memory backend cannot recover tasks after restart.")
from deerflow.mcp.client import build_server_params
for server_name, server in extensions_config.get_enabled_mcp_servers().items():
if not server.task_toolsets:
continue
try:
build_server_params(server_name, server)
except ValueError as exc:
raise McpTaskConfigurationError(str(exc)) from exc