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().
This commit is contained in:
spud 2026-09-24 17:19:01 +08:00 committed by GitHub
parent fe70cfc5ae
commit 8204dcbb20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1000 additions and 73 deletions

View File

@ -21,7 +21,7 @@
and can be removed by LRU pressure or explicit cleanup. This cannot close the
transport after its loop is gone. Normal `asyncio.run()` drains its owners.
- **Persistent stdio session capacity**: `MCPSessionPool.MAX_SESSIONS` is a hard cap on the live LRU registry. Capacity is enforced both before creating a session and when an in-flight session is promoted, because different keys can finish initialization concurrently after all observing spare capacity. Promotion-time victims are signalled and drained by separately tracked teardown tasks on their owning loops; the newly promoted owner must never await a victim, so a blocked victim exit cannot prevent its own closure or disconnect recovery.
- **Cache invalidation**: Detects extensions-config changes by comparing the resolved config path and a `(mtime, size, sha256)` content signature against the values recorded at initialization, not a strict mtime `>` comparison. This catches same-second edits, mtime that stays put or moves backward (`git checkout`, `cp -p` / backup restore, `tar` / `rsync`, object-store / network mounts), and a switch to a different config file with an equal-or-older mtime. The signature helper (`config/file_signature.py::get_config_signature`) is shared with `config/app_config.py::get_app_config()` for the sibling runtime-editable config file, rather than each maintaining its own copy. `ExtensionsConfig.resolve_config_path()` raises `FileNotFoundError` for an explicit `config_path`/`DEER_FLOW_EXTENSIONS_CONFIG_PATH` that points at a missing file — an operator-asserted path going missing is a real misconfiguration, so this is intentionally loud for callers that load the config for actual use (e.g. `from_file()` via `get_mcp_tools()`); only the fallback search mode returns `None`. The MCP cache's own path resolution (`mcp/cache.py::_resolve_config_path`) is narrower: it catches that specific `FileNotFoundError` locally and treats it the same as "unconfigured", so this staleness check degrades to "not stale" instead of propagating an exception when a previously-valid explicit/env-var config disappears mid-run. If `initialize_mcp_tools()` itself observes a config-signature change between the pre-load and post-load snapshots, that discard branch must reset the tool cache through the same session-pool retirement path as normal stale invalidation before waiters retry; otherwise a stale load can leave `(server_name, scope_key)` sessions from the abandoned connection available to the next wrapper build.
- **Cache invalidation**: Staleness is detected in two stages. First, the resolved config path and a `(mtime, size, sha256)` content signature are compared against the values recorded when the cache was published, not a strict mtime `>` comparison — this catches same-second edits, mtime that stays put or moves backward (`git checkout`, `cp -p` / backup restore, `tar` / `rsync`, object-store / network mounts), and a switch to a different config file with an equal-or-older mtime. The signature helper (`config/file_signature.py::get_config_signature`) is shared with `config/app_config.py::get_app_config()` for the sibling runtime-editable config file, rather than each maintaining its own copy. Second, because the file also carries `skills` and middleware settings, a changed signature alone is not enough: `mcp/cache.py::_effective_mcp_config_snapshot()` serializes the effective MCP slice (enabled servers' parsed config in declaration order plus `mcpInterceptors`; the raw `transport` alias is dropped so equivalent `type`/`transport` spellings compare equal) and the cache is retired only when that slice changed. A skills-only or middleware-only edit therefore adopts the new signature and keeps the cached tools and pooled sessions, while any real MCP change keeps the existing process-wide reset through `_reset_mcp_tools_cache_state_and_retire_pool_locked()`. The snapshot may embed resolved credentials, so it stays in process memory and is never logged or persisted. `_is_cache_stale()` adopts a new signature when the MCP slice is unchanged, so it is not a read-only predicate; production callers run it under `_init_condition`. `ExtensionsConfig.resolve_config_path()` raises `FileNotFoundError` for an explicit `config_path`/`DEER_FLOW_EXTENSIONS_CONFIG_PATH` that points at a missing file — an operator-asserted path going missing is a real misconfiguration, so this is intentionally loud for callers that load the config for actual use (e.g. `from_file()` via `get_mcp_tools()`); only the fallback search mode returns `None`. The MCP cache's own path resolution (`mcp/cache.py::_resolve_config_path`) is narrower: it catches that specific `FileNotFoundError` locally and treats it the same as "unconfigured", so this staleness check degrades to "not stale" instead of propagating an exception when a previously-valid explicit/env-var config disappears mid-run. Two no-config states are distinguished: `_initialized_without_config` makes a cache published while no config existed stale as soon as one appears, while a config deleted after a successful load keeps the last-known-good fail-soft behavior and stays "not stale", while a file that is present but whose content cannot be hashed is treated as stale instead, because the cache cannot prove the revision it published is still the current one. `initialize_mcp_tools()` reads one `ExtensionsConfig` instance, hands that exact object to `get_mcp_tools(extensions_config=...)`, and publishes only when the effective MCP snapshot of the current config still equals the snapshot of the revision that produced the tools; otherwise it resets the tool cache through the same session-pool retirement path as normal stale invalidation before waiters retry. This closes the discovery window — tools built from a superseded MCP revision, or from a resolved path whose signature cannot be re-read, are never published — while a skills-only edit during discovery still publishes because the MCP slice is unchanged.
- **Transports**: stdio (command-based), SSE, HTTP
- **Per-server tool-name prefixing**: `mcpServers.<server>.tool_name_prefix` defaults to `true`, preserving the collision-safe `<server_name>_` prefix. Servers whose tools already carry a stable namespace may set it to `false`; discovery then calls `langchain_mcp_adapters.tools.load_mcp_tools` with that server's flag. Source routing and stdio session-pool wrapping are based on the producing server and transport, never on whether the visible tool name starts with the server prefix.
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection. The rendered `<token_type> <access_token>` passes `mcp/headers.py::illegal_header_value_reason` inside `OAuthTokenManager.get_authorization_header` — the one boundary the tool interceptor, the initial discovery headers and the durable task path all read their value from — so a token endpoint returning something the transport would refuse fails closed instead of letting h11 echo the token into a model-visible tool error. The rendered value is what gets checked, not the two fields separately, because that is what the transport sees. The operator's static `headers` get the same check in `mcp/client.py::build_server_params`, where `build_servers_config` already drops just that server and logs the reason.
@ -41,7 +41,7 @@
- **Configured stdio working directory**: A non-empty `McpServerConfig.cwd` is forwarded by `build_server_params` for discovery, pooled Agent calls, and durable task calls. Omit the connection key when the setting is `None` or empty (including unresolved environment references), so the runtime's thread-workspace default still applies; HTTP/SSE connections must not receive it. `tests/test_mcp_cwd.py` exercises the JSON-config-to-subprocess path with relative entrypoints, relative tool inputs, and discovery/pool defaults for omitted, null, empty, and unresolved values.
- **Stdio disconnect recovery**: Ordinary Agent tool calls and durable task submit/status/cancel calls that receive the MCP SDK's explicit `Connection closed` error or an AnyIO closed-stream error evict only that `(server_name, incarnation scope)` session when the registered entry is still the same `ClientSession` that failed. A late error from an old concurrent call cannot evict its replacement or a new in-flight creation. The failing call still surfaces its original error and is never replayed automatically; a later retry creates a fresh subprocess/session. Protocol timeouts, normal `isError=true` tool results, and interceptor failures do not evict a healthy stateful session.
- **Stdio path translation**: MCP-returned local file references are not copied. If a `ResourceLink` or conservative free-text path resolves to an existing file inside the thread's mounted user-data tree, it is translated deterministically to `/mnt/user-data/...`; paths outside that tree remain unchanged.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (`PUT /api/mcp/config` keeps whole-payload validation, while `PATCH /api/mcp/config` changes only one server's `enabled` field, normalizes the same `type`/MCP-spec `transport` alias as the runtime config model, and validates the target only when enabling it; either endpoint's reset clears the cache only in its own worker). MCP, skill, and embedded-client writers hold the process-local `extensions_config_write_lock` plus the sidecar advisory `extensions_config_file_lock` for the complete read-modify-write/reload cycle, then share `atomic_write_extensions_config()`, which writes and fsyncs a same-directory temporary file before `os.replace()` and preserves an existing file's mode and symlink target; failed serialization or replacement leaves the prior config intact and cleans up the temporary file.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (`PUT /api/mcp/config` keeps whole-payload validation, while `PATCH /api/mcp/config` changes only one server's `enabled` field, normalizes the same `type`/MCP-spec `transport` alias as the runtime config model, and validates the target only when enabling it; either endpoint's reset clears the cache only in its own worker). MCP, skill, and embedded-client writers hold the process-local `extensions_config_write_lock` plus the sidecar advisory `extensions_config_file_lock` for the complete read-modify-write/reload cycle, then share `atomic_write_extensions_config()`, which writes and fsyncs a same-directory temporary file before `os.replace()` and preserves an existing file's mode and symlink target; failed serialization or replacement leaves the prior config intact and cleans up the temporary file. Runtime updates are picked up either by the writer's explicit `reset_mcp_tools_cache()` or by another worker's two-stage staleness check above; a change that leaves the effective MCP slice unchanged (skills-only, middleware-only) no longer retires cached tools or pooled sessions, and when no MCP server is enabled the tool-assembly path calls `refresh_mcp_cache_if_active()`, which retires an already-initialized cache and pool (and voids an in-flight initialization) without performing new tool discovery or config hashing on a never-initialized process. The Gateway admin reset (`POST /api/mcp/cache/reset`) is not part of that comparison: calling `reset_mcp_tools_cache()` always retires the cache and pool regardless of whether the effective MCP slice changed.
- **Stdio launch policy at the HTTP boundary** (`routers/mcp.py::_validate_mcp_update_request`, shared by `PUT` and the enable branch of `PATCH`): a config file may express anything, but the API is untrusted input, so an API-registered stdio server must (a) name a bare executable from the allowlist — `_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST` = `{npx, uvx}`, extended by `DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST`, with path separators, whitespace, and shell metacharacters rejected in `command`; (b) carry no `args` flag in `_ARBITRARY_EXEC_ARGS`; and (c) set no `env` name in `_CODE_INJECTING_ENV_VARS`. Checks (b) and (c) exist because the command check alone names a binary without constraining what that binary runs. The `env` denylist applies to **every** allowlisted command, and both denylists match `--flag=value` as well as `--flag value`. The `args` denylist's **scope depends on the command**, because where a launcher stops parsing its own flags is what decides whether a token is an exec flag at all:
- For a **package launcher** in `_PACKAGE_LAUNCHERS` (`{npx, uvx}`) only the launcher's own **option region** is screened. `npx`/`uvx` stop parsing their flags at the package name and hand every later token to the spawned server's argv, where `-c` is routinely "config" and `-e` "env" — screening those rejected ordinary third-party servers while covering nothing. A bare `--` ends the region too: only the *first* token after it is the package name. Finding that boundary needs each launcher's option **arity**, since a value is not a positional — `npx -p <pkg> -c '<command>'` **runs** the command (`-p` is `npm exec`'s `--package`, so `<pkg>` is its value and npm keeps parsing), so ending the region at the first non-flag token would walk straight past it. `_NPX_BOOLEAN_ARGS` is generated from `@npmcli/config`'s definitions (npm 10.9.4) minus the `-p` exec override; `_UVX_VALUE_ARGS` comes from `uvx --help` (uv 0.11.1). Regenerate these against a newer launcher rather than hand-editing. The unknown-option default is deliberately **opposite** per launcher, following the exec set rather than symmetry: npx owns real exec flags (`-c`/`--call`), so an unknown option consumes a value and keeps the region open (npm errors on options it does not define, so this cannot reject a working invocation); uvx owns no string-eval flag at all, so its screen is a tripwire, an unknown option consumes nothing, and uv's large boolean surface cannot over-block. uvx's exec set also drops the short spellings, because `-c` is uv's `--constraints` and `-p` its `--python`.

View File

@ -1,6 +1,7 @@
"""Cache for MCP tools to avoid repeated loading."""
import asyncio
import json
import logging
import threading
from pathlib import Path
@ -9,6 +10,7 @@ from langchain_core.tools import BaseTool
from deerflow.config.file_signature import ConfigSignature as _ConfigSignature
from deerflow.config.file_signature import get_config_signature as _get_config_signature
from deerflow.mcp.config_normalization import normalize_mcp_interceptor_paths, normalize_mcp_server_config
logger = logging.getLogger(__name__)
@ -31,6 +33,17 @@ _cache_generation = 0
_config_path: Path | None = None # Resolved extensions config path at init time
_config_signature: _ConfigSignature | None = None # (mtime, size, sha256) at init time
# JSON snapshot of the effective MCP slice (enabled servers in declaration
# order + mcpInterceptors) that the currently published tools were built from.
# May contain resolved credentials: never log or persist it.
_mcp_config_snapshot: str | None = None
# True when the published cache came from an initialization with no resolvable
# extensions config. Distinguishes "never configured" (a later config must be
# picked up) from "config deleted after a successful load" (fail-soft: keep
# serving the last-known-good tools).
_initialized_without_config = False
def _resolve_config_path() -> Path | None:
"""Resolve the extensions config file path, or ``None`` when unconfigured.
@ -76,18 +89,99 @@ def _current_config_state() -> tuple[Path | None, _ConfigSignature | None]:
return config_path, _get_config_signature(config_path)
def _effective_mcp_config_snapshot(config) -> str:
"""Serialize the MCP-only slice of an extensions config.
``extensions_config.json`` also carries skills and middleware settings, so
the whole-file signature cannot distinguish an MCP change from a skill
toggle. The enabled-server list preserves declaration order (it can affect
tool ordering) while ``sort_keys`` only normalizes each server's field
order. Parsed models are compared through
``config_normalization.normalize_mcp_server_config`` (equivalent
``type``/``transport`` spellings) and the custom interceptor list through
``config_normalization.normalize_mcp_interceptor_paths`` (a bare string and
its single-element list, or a missing key and an empty list), so equivalent
spellings do not cause a needless rebuild.
The result may embed resolved credentials. It stays in process memory and
is never logged or written back to disk.
"""
relevant = {
"enabled_servers": [(name, normalize_mcp_server_config(server)) for name, server in config.get_enabled_mcp_servers().items()],
"mcpInterceptors": normalize_mcp_interceptor_paths((config.model_extra or {}).get("mcpInterceptors")),
}
return json.dumps(relevant, sort_keys=True, ensure_ascii=False)
def _signature_is_verifiable(signature: _ConfigSignature | None) -> bool:
"""True when a config signature carries a content digest.
``get_config_signature`` returns ``(mtime, size, None)`` when the file could
be stat-ed but not read. Such a signature cannot prove the content is
unchanged, so the MCP cache must neither publish nor adopt it.
"""
return signature is not None and signature[2] is not None
def _read_stable_mcp_snapshot(config_path: Path, expected_signature: _ConfigSignature) -> str | None:
"""Parse the config and return its MCP snapshot only if the file was stable.
``expected_signature`` is the signature observed by the caller. A signature
without a content digest is treated as unverifiable, and the file is
re-hashed after parsing: a mismatch means the config changed while it was
being read, so no snapshot can be attributed to a single revision and the
caller must treat the cache as stale. Parse failures also return ``None``
(conservative: never reuse tools on an unreadable config).
"""
if not _signature_is_verifiable(expected_signature):
logger.info("Extensions config signature has no content digest; treating the MCP cache as stale")
return None
from deerflow.config.extensions_config import ExtensionsConfig
try:
config = ExtensionsConfig.from_file(str(config_path))
except Exception as exc:
# Do NOT pass exc_info/message: ExtensionsConfig.from_file resolves
# ``$VAR`` values before validation, so a ValidationError can embed
# resolved credentials in its input. Only the exception type is safe.
logger.warning(
"Could not parse extensions config while checking MCP cache staleness (%s); treating the cache as stale",
type(exc).__name__,
)
return None
current_signature = _get_config_signature(config_path)
if not _signature_is_verifiable(current_signature) or current_signature != expected_signature:
logger.info("Extensions config changed while it was being read; treating the MCP cache as stale")
return None
return _effective_mcp_config_snapshot(config)
def _is_cache_stale() -> bool:
"""Check if the cache is stale due to config file changes.
The cache is stale when the resolved extensions config path changed, or when
the ``(mtime, size, sha256)`` content signature differs from the one recorded
at initialization. Using content equality (``!=``) instead of a strict mtime
``>`` comparison detects same-second edits and backward mtime moves, and
tracking the resolved path detects a switch to a different config file.
The cache is stale when the effective MCP slice of the resolved config
differs from the one recorded at initialization. The resolved path and the
``(mtime, size, sha256)`` content signature are the change signals that
trigger that comparison, not invalidation reasons on their own. Using
content equality (``!=``) instead of a strict mtime ``>`` comparison detects
same-second edits and backward mtime moves. A path switch to a file with the
same MCP configuration, or an edit that only touches skills or middleware
settings, therefore keeps the cached tools and sessions and adopts the new
path and signature.
When the file signature changes but the effective MCP configuration does
not, this function adopts the new signature into ``_config_signature`` and
returns False. It is therefore not a read-only predicate; production callers
invoke it under ``_init_condition``, the same lock the mutating paths use.
Returns:
True if the cache should be invalidated, False otherwise.
"""
global _config_path, _config_signature
if not _cache_initialized:
return False # Not initialized yet, not stale
@ -103,18 +197,38 @@ def _is_cache_stale() -> bool:
# longer be stat-ed). Treat this as a deliberate fail-soft choice, not an
# oversight — a future change that wants "config deleted" to tear down
# MCP tools needs its own explicit signal here, not an inferred one.
if _config_signature is None or current_signature is None:
if _config_signature is None:
# A config that appears after an unconfigured initialization must be
# picked up; a config deleted after a successful load keeps the
# last-known-good fail-soft contract below.
if _initialized_without_config and current_signature is not None:
logger.info("Extensions config appeared after an unconfigured MCP cache; cache is stale")
return True
return False
if current_signature is None:
return False
if current_path == _config_path and current_signature == _config_signature and _signature_is_verifiable(current_signature):
return False
# The resolved path and/or the file content changed. Both are only signals to
# re-read the effective MCP slice: the file also carries skills and middleware
# settings, and a path switch can land on a file with the same MCP
# configuration, so neither is an invalidation reason on its own.
if current_path != _config_path:
logger.info("MCP config path changed (%s -> %s), cache is stale", _config_path, current_path)
return True
logger.info("MCP config path changed (%s -> %s); re-checking the effective MCP configuration", _config_path, current_path)
if current_signature != _config_signature:
logger.info("MCP config content changed (signature %s -> %s), cache is stale", _config_signature, current_signature)
return True
if current_path is not None and _mcp_config_snapshot is not None:
current_snapshot = _read_stable_mcp_snapshot(current_path, current_signature)
if current_snapshot is not None and current_snapshot == _mcp_config_snapshot:
logger.info("Extensions config changed but the effective MCP configuration did not; keeping cached MCP tools and sessions")
_config_path = current_path
_config_signature = current_signature
return False
return False
logger.info("MCP config content changed (signature %s -> %s), cache is stale", _config_signature, current_signature)
return True
def _wait_for_initialization(generation: int | None) -> None:
@ -132,7 +246,7 @@ async def initialize_mcp_tools() -> list[BaseTool]:
List of LangChain tools from all enabled MCP servers.
"""
global _mcp_tools_cache, _cache_initialized, _config_path, _config_signature
global _initializing_generation, _cache_generation
global _initializing_generation, _cache_generation, _mcp_config_snapshot, _initialized_without_config
while True:
with _init_condition:
@ -141,7 +255,6 @@ async def initialize_mcp_tools() -> list[BaseTool]:
return _mcp_tools_cache or []
if _initializing_generation is None:
pre_path, pre_sig = _current_config_state()
claim_generation = _cache_generation
_initializing_generation = claim_generation
break
@ -150,16 +263,56 @@ async def initialize_mcp_tools() -> list[BaseTool]:
await asyncio.to_thread(_wait_for_initialization, waiting_generation)
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.tools import get_mcp_tools
loaded_tools = None
loaded_snapshot = None
post_path = None
post_sig = None
post_snapshot = None
init_succeeded = False
try:
logger.info("Initializing MCP tools...")
loaded_tools = await get_mcp_tools()
# Read the exact revision we hand to discovery. Comparing pre/post file
# snapshots alone cannot prove which revision produced the tools, because
# get_mcp_tools() would otherwise read the file itself.
try:
loaded_config = ExtensionsConfig.from_file()
except Exception as exc:
# Never let a resolved-credential ValidationError reach a caller's
# logger: from_file() resolves $VAR values before validation, so the
# exception message can embed secrets. Re-raise a sanitized error;
# other MCP failures keep their original traceback.
logger.warning(
"Could not load extensions config before MCP tool discovery (%s); aborting initialization",
type(exc).__name__,
)
raise RuntimeError("Extensions config could not be loaded for MCP tool discovery") from None
loaded_snapshot = _effective_mcp_config_snapshot(loaded_config)
loaded_tools = await get_mcp_tools(extensions_config=loaded_config)
post_path, post_sig = _current_config_state()
if post_path is not None and post_sig is not None:
post_snapshot = _read_stable_mcp_snapshot(post_path, post_sig)
elif post_path is not None:
# The path resolved but its signature could not be read. Publishing
# here would record an unpinned cache that later checks could never
# invalidate, so discard instead.
post_snapshot = None
else:
# No resolvable config now. Re-resolve after the fallback read so a
# config that appeared mid-flight is not published as an unpinned
# cache; the snapshot comparison still rejects a different revision.
try:
fallback_snapshot = _effective_mcp_config_snapshot(ExtensionsConfig.from_file())
except Exception as exc:
logger.warning(
"Could not load extensions config after MCP tool discovery (%s); discarding result",
type(exc).__name__,
)
fallback_snapshot = None
recheck_path, recheck_sig = _current_config_state()
post_snapshot = fallback_snapshot if recheck_path is None and recheck_sig is None else None
init_succeeded = True
finally:
if not init_succeeded:
@ -175,13 +328,16 @@ async def initialize_mcp_tools() -> list[BaseTool]:
logger.info("MCP cache was reset during initialization; discarding stale result")
return []
if (pre_path, pre_sig) != (post_path, post_sig):
publish = loaded_snapshot is not None and post_snapshot is not None and loaded_snapshot == post_snapshot
if not publish:
logger.warning("MCP config changed during initialization; discarding stale result")
retired_pool = _reset_mcp_tools_cache_state_and_retire_pool_locked()
else:
_mcp_tools_cache = loaded_tools
_cache_initialized = True
_config_path, _config_signature = post_path, post_sig
_mcp_config_snapshot = post_snapshot
_initialized_without_config = post_path is None
logger.info("MCP tools initialized: %d tool(s) loaded (config path: %s)", len(_mcp_tools_cache), _config_path)
return _mcp_tools_cache
finally:
@ -250,15 +406,51 @@ def get_cached_mcp_tools() -> list[BaseTool]:
return _mcp_tools_cache or []
def refresh_mcp_cache_if_active() -> bool:
"""Retire stale MCP cache state without lazily initializing tools.
Tool assembly skips ``get_cached_mcp_tools()`` when no MCP server is
enabled, so a config change that disables the last server would otherwise
leave the previous pool and its persistent sessions alive. This entry point
performs only the staleness check:
* it returns immediately when no MCP state was ever initialized and no
initialization is in flight, so a process that never initialized MCP
tools pays no config-hashing cost. A process that did publish a cache
(including an empty one) still pays one stat+sha256 of the config file
per call to check that cache for staleness;
* it invalidates an in-flight initialization (bumping the cache generation)
so tools discovered under the superseded config cannot publish;
* it retires the pool outside the lock, matching ``get_cached_mcp_tools``.
Returns:
True when existing cache state or an in-flight initialization was
retired.
"""
retired_pool = None
retired = False
with _init_condition:
if not _cache_initialized and _initializing_generation is None:
return False
if _initializing_generation is not None or _is_cache_stale():
retired_pool = _reset_mcp_tools_cache_state_and_retire_pool_locked()
retired = True
if retired_pool is not None:
retired_pool.close_all_sync()
return retired
def _reset_mcp_tools_cache_state() -> None:
"""Reset cache state under ``_init_condition`` / ``_init_lock``."""
global _mcp_tools_cache, _cache_initialized, _config_path, _config_signature
global _cache_generation
global _cache_generation, _mcp_config_snapshot, _initialized_without_config
_mcp_tools_cache = None
_cache_initialized = False
_config_path = None
_config_signature = None
_mcp_config_snapshot = None
_initialized_without_config = False
_cache_generation += 1
_init_condition.notify_all()

View File

@ -0,0 +1,46 @@
"""Shared normalization of MCP configuration values.
The MCP tools cache, the custom-interceptor builder and the durable-task
configuration snapshot must all agree on when two configs are equivalent.
Keeping the rules here stops those three call sites from drifting apart.
"""
from __future__ import annotations
from typing import Any
# Server fields that only affect how tools are presented or routed to the
# model, never how a durable-task server is contacted or driven.
TASK_PRESENTATION_FIELDS = ("description", "routing", "tools", "tool_name_prefix")
def normalize_mcp_interceptor_paths(raw: Any) -> list[Any]:
"""Return the custom interceptor paths a raw ``mcpInterceptors`` value selects.
Mirrors what ``build_mcp_tool_interceptors`` consumes: a bare string is one
path, a list is used verbatim (order and duplicates preserved), and any
other value — including a missing key and ``None`` — selects no custom
interceptors.
"""
if isinstance(raw, str):
return [raw]
if isinstance(raw, list):
return list(raw)
return []
def normalize_mcp_server_config(server: Any, *, task_runtime: bool = False) -> dict[str, Any]:
"""Return a parsed server config with alias-only fields normalized away.
``transport`` is an MCP-spec alias for ``type``: the model validator copies
it onto ``type`` but keeps the raw extra key (``extra="allow"``), so dropping
it makes equivalent ``type``/``transport`` spellings compare equal. With
``task_runtime=True`` the presentation-only fields are dropped too, matching
what the durable-task runtime actually depends on.
"""
dumped = server.model_dump(mode="json")
dumped.pop("transport", None)
if task_runtime:
for field in TASK_PRESENTATION_FIELDS:
dumped.pop(field, None)
return dumped

View File

@ -6,6 +6,7 @@ import logging
from typing import Any
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.config_normalization import normalize_mcp_interceptor_paths
from deerflow.mcp.context_headers import build_context_headers_interceptor
from deerflow.mcp.oauth import build_oauth_tool_interceptor
from deerflow.mcp.user_scoped_auth import build_user_scoped_auth_interceptor
@ -44,16 +45,13 @@ def build_mcp_tool_interceptors(
if context_headers_interceptor is not None:
interceptors.append(context_headers_interceptor)
raw_paths = (extensions_config.model_extra or {}).get("mcpInterceptors")
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
elif not isinstance(raw_paths, list):
if raw_paths is not None:
target_logger.warning(
"mcpInterceptors must be a list of strings, got %s; skipping",
type(raw_paths).__name__,
)
raw_paths = []
raw_value = (extensions_config.model_extra or {}).get("mcpInterceptors")
if raw_value is not None and not isinstance(raw_value, (str, list)):
target_logger.warning(
"mcpInterceptors must be a list of strings, got %s; skipping",
type(raw_value).__name__,
)
raw_paths = normalize_mcp_interceptor_paths(raw_value)
for interceptor_path in raw_paths:
try:

View File

@ -5,6 +5,7 @@ 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
@ -51,11 +52,9 @@ def _task_server_configs(extensions_config: ExtensionsConfig) -> _TaskServerConf
for server_name, server in extensions_config.get_enabled_mcp_servers().items():
if not server.task_toolsets:
continue
runtime_config = server.model_dump(mode="json")
for presentation_field in ("description", "routing", "tools", "tool_name_prefix"):
runtime_config.pop(presentation_field, None)
servers[server_name] = runtime_config
interceptors = (extensions_config.model_extra or {}).get("mcpInterceptors") if servers else None
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

View File

@ -780,7 +780,7 @@ def _configure_task_tools_for_server(
return configured
async def get_mcp_tools() -> list[BaseTool]:
async def get_mcp_tools(extensions_config: ExtensionsConfig | None = None) -> list[BaseTool]:
"""Get all tools from enabled MCP servers.
Tools using stdio transport are wrapped with persistent-session logic so
@ -788,6 +788,11 @@ async def get_mcp_tools() -> list[BaseTool]:
HTTP/SSE tools are returned unwrapped to avoid cross-task TaskGroup
cleanup errors.
Args:
extensions_config: Optional pre-loaded extensions config. Callers that
must prove which config revision produced these tools pass the exact
instance they snapshotted; ``None`` loads the latest config from disk.
Returns:
List of LangChain tools from all enabled MCP servers.
"""
@ -798,11 +803,13 @@ async def get_mcp_tools() -> list[BaseTool]:
logger.warning("langchain-mcp-adapters not installed. Install it to enable MCP tools: pip install langchain-mcp-adapters")
return []
# NOTE: We use ExtensionsConfig.from_file() instead of get_extensions_config()
# to always read the latest configuration from disk. This ensures that changes
# made through the Gateway API (which runs in a separate process) are immediately
# reflected when initializing MCP tools.
extensions_config = ExtensionsConfig.from_file()
if extensions_config is None:
# NOTE: We use ExtensionsConfig.from_file() instead of get_extensions_config()
# to always read the latest configuration from disk. This ensures that changes
# made through the Gateway API (which runs in a separate process) are immediately
# reflected when initializing MCP tools. Callers that need to prove which
# revision produced these tools pass the instance they snapshotted instead.
extensions_config = ExtensionsConfig.from_file()
validate_mcp_task_config_snapshot(extensions_config)
servers_config = build_servers_config(extensions_config)

View File

@ -233,29 +233,45 @@ def get_available_tools(
if include_mcp:
try:
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.cache import get_cached_mcp_tools
from deerflow.mcp.cache import get_cached_mcp_tools, refresh_mcp_cache_if_active
extensions_config = ExtensionsConfig.from_file()
if extensions_config.get_enabled_mcp_servers():
mcp_tools = get_cached_mcp_tools()
if mcp_tools:
logger.info(f"Using {len(mcp_tools)} cached MCP tool(s)")
try:
extensions_config = ExtensionsConfig.from_file()
except Exception as exc:
# Only this call carries the resolved-credential risk:
# from_file() resolves $VAR values before validation, so a
# ValidationError message can embed secrets. Log the type only.
logger.error("Failed to load MCP extensions config (%s)", type(exc).__name__)
else:
if extensions_config.get_enabled_mcp_servers():
mcp_tools = get_cached_mcp_tools()
if mcp_tools:
logger.info(f"Using {len(mcp_tools)} cached MCP tool(s)")
# Tag MCP-sourced tools so deferred-tool assembly at each
# agent construction site can identify them. Lead agents
# assemble their full configured MCP catalog and apply active
# skill policy at runtime; subagents may pass an already
# policy-filtered list because their skills load at startup.
for t in mcp_tools:
tag_mcp_tool(t)
if mcp_plugins is not None:
from deerflow.capabilities.runtime import filter_mcp_plugins
# Tag MCP-sourced tools so deferred-tool assembly at each
# agent construction site can identify them. Lead agents
# assemble their full configured MCP catalog and apply active
# skill policy at runtime; subagents may pass an already
# policy-filtered list because their skills load at startup.
for t in mcp_tools:
tag_mcp_tool(t)
else:
# A change that disables the last MCP server must still retire
# the previously initialized cache and its pooled sessions.
# This never initializes tools: a process that never initialized
# MCP pays no config-hashing or discovery cost, while one that
# did still checks the existing cache for staleness.
refresh_mcp_cache_if_active()
if mcp_plugins is not None:
from deerflow.capabilities.runtime import filter_mcp_plugins
mcp_tools = filter_mcp_plugins(mcp_tools, mcp_plugins, extensions_config)
mcp_tools = filter_mcp_plugins(mcp_tools, mcp_plugins, extensions_config)
except ImportError:
logger.warning("MCP module not available. Install 'langchain-mcp-adapters' package to enable MCP tools.")
except Exception as e:
logger.error(f"Failed to get cached MCP tools: {e}")
except Exception:
# Tool caching, pool cleanup and plugin filtering raise ordinary
# exceptions whose messages are safe and needed for debugging.
logger.exception("Failed to get cached MCP tools")
# Add invoke_acp_agent tool if any ACP agents are configured
acp_tools: list[BaseTool] = []

View File

@ -22,6 +22,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import threading
from pathlib import Path
@ -30,6 +31,7 @@ import pytest
import deerflow.mcp.cache as cache_module
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.file_signature import get_config_signature
_MISSING = object()
@ -47,11 +49,22 @@ _TRACKED_GLOBALS = (
"_init_condition",
"_initializing_generation",
"_cache_generation",
"_mcp_config_snapshot",
"_initialized_without_config",
)
def _write_extensions_config(path: Path, servers: dict) -> None:
path.write_text(json.dumps({"mcpServers": servers, "skills": {}}), encoding="utf-8")
def _write_extensions_config(
path: Path,
servers: dict,
*,
skills: dict | None = None,
interceptors: list | str | None = None,
) -> None:
payload: dict = {"mcpServers": servers, "skills": skills or {}}
if interceptors is not None:
payload["mcpInterceptors"] = interceptors
path.write_text(json.dumps(payload), encoding="utf-8")
def _server(command: str = "npx") -> dict:
@ -65,7 +78,7 @@ def cache_globals():
cache_module._mcp_tools_cache = None
cache_module._cache_initialized = False
for name in ("_config_path", "_config_signature", "_config_mtime"):
for name in ("_config_path", "_config_signature", "_config_mtime", "_mcp_config_snapshot", "_initialized_without_config"):
if hasattr(cache_module, name):
setattr(cache_module, name, None)
# threading.Lock is safe across threads and does not bind to event loops,
@ -95,7 +108,7 @@ def _initialize_against(monkeypatch, config_path: Path) -> None:
"""
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
async def _fake_get_mcp_tools():
async def _fake_get_mcp_tools(**_kwargs):
return []
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
@ -316,7 +329,7 @@ class TestCrossLoopReinitialization:
_write_extensions_config(cfg, {"srv1": _server()})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
async def _fake_tools():
async def _fake_tools(**_kwargs):
return []
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_tools)
@ -357,7 +370,7 @@ class TestCrossLoopReinitialization:
calls = 0
async def _fake_tools():
async def _fake_tools(**_kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0.01)
@ -404,7 +417,7 @@ class TestCrossLoopReinitialization:
_write_extensions_config(cfg, {"srv1": _server()})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
async def _fake_tools():
async def _fake_tools(**_kwargs):
return []
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_tools)
@ -436,7 +449,7 @@ def test_config_change_during_initialization_discards_stale_tools(cache_globals,
calls = 0
async def _fake_tools():
async def _fake_tools(**_kwargs):
nonlocal calls
calls += 1
if calls == 1:
@ -501,7 +514,7 @@ def test_config_change_during_initialization_retires_pool_for_same_server_connec
loaded_pools = []
loaded_sessions = []
async def _fake_tools():
async def _fake_tools(**_kwargs):
nonlocal calls
calls += 1
server = json.loads(cfg.read_text())["mcpServers"]["same"]
@ -541,7 +554,7 @@ def test_reset_mcp_tools_cache_does_not_wait_for_in_flight_initialization(cache_
started = threading.Event()
finish = threading.Event()
async def _fake_tools():
async def _fake_tools(**_kwargs):
started.set()
await asyncio.to_thread(finish.wait)
return []
@ -593,7 +606,7 @@ def test_automatic_stale_invalidation_retires_session_pool_before_reinitializing
_write_extensions_config(cfg, {"new": _server("uvx")})
loaded_pools = []
async def _fake_tools():
async def _fake_tools(**_kwargs):
loaded_pools.append(session_pool_module.get_session_pool())
return ["new-tools"]
@ -623,7 +636,7 @@ def test_reset_mcp_tools_cache_retires_session_pool_before_releasing_initializer
race_results = []
loaded_pools = []
async def _fake_tools():
async def _fake_tools(**_kwargs):
loaded_pools.append(session_pool_module.get_session_pool())
return ["race-tools"]
@ -662,7 +675,7 @@ def test_cancelled_initializer_releases_generation_claim(cache_globals, monkeypa
release = asyncio.Event()
calls = 0
async def _fake_tools():
async def _fake_tools(**_kwargs):
nonlocal calls
calls += 1
started.set()
@ -690,3 +703,506 @@ def test_cancelled_initializer_releases_generation_claim(cache_globals, monkeypa
assert cache_module._cache_initialized is True
assert cache_module._initializing_generation is None
assert calls == 2
# ---------------------------------------------------------------------------
# Effective-MCP-config invalidation
#
# ``extensions_config.json`` carries both ``mcpServers`` and ``skills``, and the
# cache signature covers the whole file. Before this fix any skill edit closed
# every pooled MCP session even though no MCP server changed. These tests pin
# the narrowed contract: only the effective MCP slice may retire MCP state.
# ---------------------------------------------------------------------------
def test_skills_only_change_keeps_tools_and_pool(cache_globals, monkeypatch, tmp_path):
"""Toggling a skill must not rebuild tools, replace the pool, or close sessions."""
from deerflow.mcp.session_pool import get_session_pool, reset_session_pool
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
sentinel_tool = object()
async def _fake_get_mcp_tools(**_kwargs):
return [sentinel_tool]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
asyncio.run(cache_module.initialize_mcp_tools())
old_pool = get_session_pool()
closed: list[bool] = []
monkeypatch.setattr(old_pool, "close_all_sync", lambda: closed.append(True))
try:
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": False}})
assert cache_module.get_cached_mcp_tools() == [sentinel_tool]
assert cache_module._mcp_tools_cache == [sentinel_tool]
assert get_session_pool() is old_pool
assert closed == []
assert cache_module._config_signature == get_config_signature(cfg)
finally:
reset_session_pool()
def test_same_mtime_same_size_skills_only_change_is_not_stale(cache_globals, monkeypatch, tmp_path):
"""A skills-only edit that hides behind mtime+size still updates the checked signature."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
_initialize_against(monkeypatch, cfg)
recorded_mtime = cfg.stat().st_mtime
recorded_size = cfg.stat().st_size
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-b": {"enabled": True}})
os.utime(cfg, (recorded_mtime, recorded_mtime))
assert cfg.stat().st_mtime == recorded_mtime
assert cfg.stat().st_size == recorded_size
assert cache_module._is_cache_stale() is False
assert cache_module._config_signature == get_config_signature(cfg)
def test_mcp_connection_change_is_still_stale(cache_globals, monkeypatch, tmp_path):
"""A real MCP change must keep retiring the cache (no over-narrowing)."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": _server("uvx")})
assert cache_module._is_cache_stale() is True
def test_mcp_interceptors_change_is_still_stale(cache_globals, monkeypatch, tmp_path):
"""A global mcpInterceptors change must keep retiring the cache."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, interceptors=["pkg.a:build"])
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": _server()}, interceptors=["pkg.b:build"])
assert cache_module._is_cache_stale() is True
def test_explicit_reset_still_clears_after_skills_only_change(cache_globals, monkeypatch, tmp_path):
"""The semantic comparison must never suppress an explicit global reset."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": False}})
assert cache_module.get_cached_mcp_tools() == []
cache_module.reset_mcp_tools_cache()
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
assert cache_module._mcp_config_snapshot is None
def test_malformed_config_is_still_stale(cache_globals, monkeypatch, tmp_path):
"""An unreadable config must never be mistaken for a skills-only no-op."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
_initialize_against(monkeypatch, cfg)
cfg.write_text("{not json", encoding="utf-8")
assert cache_module._is_cache_stale() is True
def test_refresh_is_noop_before_initialization(cache_globals, monkeypatch):
"""Deployments that never initialized MCP pay no config-hashing cost."""
calls: list[str] = []
monkeypatch.setattr(
cache_module,
"_current_config_state",
lambda: calls.append("checked") or (None, None),
)
assert cache_module.refresh_mcp_cache_if_active() is False
assert calls == []
def test_refresh_retires_cache_when_last_server_is_disabled(cache_globals, monkeypatch, tmp_path):
"""Disabling the last server still converges an already-initialized cache."""
from deerflow.mcp.session_pool import get_session_pool, reset_session_pool
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
_initialize_against(monkeypatch, cfg)
old_pool = get_session_pool()
closed: list[bool] = []
monkeypatch.setattr(old_pool, "close_all_sync", lambda: closed.append(True))
_write_extensions_config(cfg, {})
try:
assert cache_module.refresh_mcp_cache_if_active() is True
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
assert cache_module._mcp_config_snapshot is None
assert get_session_pool() is not old_pool
assert closed == [True]
finally:
reset_session_pool()
def test_refresh_keeps_cache_when_only_skills_changed(cache_globals, monkeypatch, tmp_path):
"""The lightweight refresh path honours the effective-MCP comparison."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": False}})
assert cache_module.refresh_mcp_cache_if_active() is False
assert cache_module._cache_initialized is True
def test_skills_only_change_during_initialization_publishes_loaded_tools(cache_globals, monkeypatch, tmp_path):
"""A skills-only edit during tool discovery must not force a needless rebuild."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
calls = 0
async def _fake_tools(**_kwargs):
nonlocal calls
calls += 1
_write_extensions_config(cfg, {"srv1": _server()}, skills={"skill-a": {"enabled": False}})
return ["loaded-tools"]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_tools)
result = asyncio.run(cache_module.initialize_mcp_tools())
assert result == ["loaded-tools"]
assert cache_module._cache_initialized is True
assert cache_module._mcp_tools_cache == ["loaded-tools"]
assert calls == 1
def test_refresh_during_initialization_discards_stale_publish(cache_globals, monkeypatch, tmp_path):
"""Disabling the last server while discovery is in flight must void the result."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
started = threading.Event()
release = threading.Event()
async def _fake_tools(**_kwargs):
started.set()
while not release.is_set():
await asyncio.sleep(0.01)
return ["stale-tools"]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_tools)
async def _run():
owner = asyncio.create_task(cache_module.initialize_mcp_tools())
while not started.is_set():
await asyncio.sleep(0.01)
_write_extensions_config(cfg, {})
assert cache_module.refresh_mcp_cache_if_active() is True
release.set()
return await asyncio.wait_for(owner, timeout=2)
assert asyncio.run(_run()) == []
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
def test_initialization_without_a_readable_snapshot_discards_result(cache_globals, monkeypatch, tmp_path):
"""Never publish tools that cannot be tied to a parsed MCP snapshot."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
async def _fake_get_mcp_tools(**_kwargs):
return ["loaded-tools"]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
monkeypatch.setattr(cache_module, "_read_stable_mcp_snapshot", lambda path, signature: None)
result = asyncio.run(cache_module.initialize_mcp_tools())
assert result == []
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
def test_parse_failure_log_does_not_leak_resolved_credentials(cache_globals, monkeypatch, tmp_path, caplog):
"""A malformed config must not echo resolved $VAR secrets into the log."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
_initialize_against(monkeypatch, 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",
)
with caplog.at_level(logging.WARNING):
assert cache_module._is_cache_stale() is True
assert "TOPSECRET123" not in caplog.text
def test_equivalent_transport_alias_spellings_are_not_stale(cache_globals, monkeypatch, tmp_path):
"""``type=stdio`` and ``transport=stdio`` describe the same connection."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": {"enabled": True, "type": "stdio", "command": "npx"}})
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": {"enabled": True, "transport": "stdio", "command": "npx"}})
assert cache_module._is_cache_stale() is False
def test_initialization_hands_the_snapshotted_config_to_discovery(cache_globals, monkeypatch, tmp_path):
"""Discovery must use the exact revision the cache snapshotted, not re-read."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server("npx")})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
seen: dict[str, str] = {}
async def _fake_tools(*, extensions_config):
seen["command"] = extensions_config.mcp_servers["srv1"].command
# A transient flip to Y and back to X must not invalidate X-built tools.
_write_extensions_config(cfg, {"srv1": _server("uvx")})
_write_extensions_config(cfg, {"srv1": _server("npx")})
return ["loaded-tools"]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_tools)
result = asyncio.run(cache_module.initialize_mcp_tools())
assert seen["command"] == "npx"
assert result == ["loaded-tools"]
assert cache_module._cache_initialized is True
assert cache_module._mcp_config_snapshot is not None
def test_config_appearing_after_unconfigured_init_is_stale(cache_globals, monkeypatch, tmp_path):
"""A config that appears after an unconfigured init must be picked up."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
state: dict[str, object] = {"path": None}
monkeypatch.setattr(
ExtensionsConfig,
"resolve_config_path",
classmethod(lambda cls, config_path=None: state["path"]),
)
async def _fake_get_mcp_tools(**_kwargs):
return []
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
asyncio.run(cache_module.initialize_mcp_tools())
assert cache_module._cache_initialized is True
assert cache_module._config_signature is None
assert cache_module._initialized_without_config is True
state["path"] = cfg
assert cache_module._is_cache_stale() is True
def test_initialization_with_unreadable_post_signature_discards_result(cache_globals, monkeypatch, tmp_path):
"""A resolved path with an unreadable signature must not publish an unpinned cache."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
async def _fake_get_mcp_tools(**_kwargs):
return ["loaded-tools"]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
monkeypatch.setattr(cache_module, "_current_config_state", lambda: (cfg, None))
result = asyncio.run(cache_module.initialize_mcp_tools())
assert result == []
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
def test_lazy_init_does_not_leak_credentials_on_malformed_config(cache_globals, monkeypatch, tmp_path, caplog):
"""Lazy init must not echo resolved secrets when the config is malformed."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
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",
)
with caplog.at_level(logging.WARNING):
assert cache_module.get_cached_mcp_tools() == []
assert "TOPSECRET123" not in caplog.text
def _assert_cache_survives_edit(monkeypatch, cfg, sentinel, rewrite):
"""Initialize with *sentinel*, apply *rewrite*, and prove nothing was retired."""
from deerflow.mcp.session_pool import get_session_pool, reset_session_pool
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
async def _fake_get_mcp_tools(**_kwargs):
return [sentinel]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
asyncio.run(cache_module.initialize_mcp_tools())
old_pool = get_session_pool()
closed: list[bool] = []
monkeypatch.setattr(old_pool, "close_all_sync", lambda: closed.append(True))
try:
rewrite()
assert cache_module.get_cached_mcp_tools() == [sentinel]
assert cache_module._mcp_tools_cache == [sentinel]
assert get_session_pool() is old_pool
assert closed == []
finally:
reset_session_pool()
def test_string_and_single_element_interceptor_list_are_equivalent(cache_globals, monkeypatch, tmp_path):
"""A bare string and its single-element list select the same interceptor chain."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, interceptors="pkg.interceptor:build")
sentinel = object()
_assert_cache_survives_edit(
monkeypatch,
cfg,
sentinel,
lambda: _write_extensions_config(cfg, {"srv1": _server()}, interceptors=["pkg.interceptor:build"]),
)
def test_missing_and_empty_interceptors_are_equivalent(cache_globals, monkeypatch, tmp_path):
"""An absent key and an empty list both select no custom interceptors."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
sentinel = object()
_assert_cache_survives_edit(
monkeypatch,
cfg,
sentinel,
lambda: _write_extensions_config(cfg, {"srv1": _server()}, interceptors=[]),
)
def test_interceptor_order_change_is_still_stale(cache_globals, monkeypatch, tmp_path):
"""Order is part of the effective chain and must keep retiring the cache."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()}, interceptors=["a:build", "b:build"])
_initialize_against(monkeypatch, cfg)
_write_extensions_config(cfg, {"srv1": _server()}, interceptors=["b:build", "a:build"])
assert cache_module._is_cache_stale() is True
def test_path_switch_with_equivalent_mcp_config_keeps_tools_and_pool(cache_globals, monkeypatch, tmp_path):
"""Switching to a file whose MCP slice is identical must not retire sessions."""
from deerflow.mcp.session_pool import get_session_pool, reset_session_pool
cfg_a = tmp_path / "a_extensions_config.json"
cfg_b = tmp_path / "b_extensions_config.json"
_write_extensions_config(cfg_a, {"srv1": _server()}, skills={"skill-a": {"enabled": True}})
_write_extensions_config(cfg_b, {"srv1": _server()}, skills={"skill-a": {"enabled": False}})
state: dict[str, object] = {"path": cfg_a}
monkeypatch.setattr(
ExtensionsConfig,
"resolve_config_path",
classmethod(lambda cls, config_path=None: state["path"]),
)
sentinel = object()
async def _fake_get_mcp_tools(**_kwargs):
return [sentinel]
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", _fake_get_mcp_tools)
asyncio.run(cache_module.initialize_mcp_tools())
old_pool = get_session_pool()
closed: list[bool] = []
monkeypatch.setattr(old_pool, "close_all_sync", lambda: closed.append(True))
try:
state["path"] = cfg_b
assert cache_module.get_cached_mcp_tools() == [sentinel]
assert get_session_pool() is old_pool
assert closed == []
assert cache_module._config_path == cfg_b
assert cache_module._config_signature == get_config_signature(cfg_b)
finally:
reset_session_pool()
def test_path_switch_with_different_mcp_config_is_still_stale(cache_globals, monkeypatch, tmp_path):
"""A path switch to a file with a different MCP slice keeps the global reset."""
cfg_a = tmp_path / "a_extensions_config.json"
cfg_b = tmp_path / "b_extensions_config.json"
_write_extensions_config(cfg_a, {"srv1": _server("npx")})
_write_extensions_config(cfg_b, {"srv1": _server("uvx")})
state: dict[str, object] = {"path": cfg_a}
monkeypatch.setattr(
ExtensionsConfig,
"resolve_config_path",
classmethod(lambda cls, config_path=None: state["path"]),
)
_initialize_against(monkeypatch, cfg_a)
state["path"] = cfg_b
assert cache_module._is_cache_stale() is True
def test_unverifiable_signature_is_not_treated_as_stable(cache_globals, monkeypatch, tmp_path):
"""A signature without a sha256 digest must never be published as stable."""
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"srv1": _server()})
_initialize_against(monkeypatch, cfg)
real = cache_module._get_config_signature
def _no_digest(path):
signature = real(path)
return None if signature is None else (signature[0], signature[1], None)
monkeypatch.setattr(cache_module, "_get_config_signature", _no_digest)
incomplete = _no_digest(cfg)
assert incomplete is not None and incomplete[2] is None
assert cache_module._read_stable_mcp_snapshot(cfg, incomplete) is None
assert cache_module._is_cache_stale() is True

View File

@ -0,0 +1,111 @@
"""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)

View File

@ -137,3 +137,37 @@ def test_mcp_interceptor_changes_remain_hot_reloadable_without_task_tools() -> N
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)

View File

@ -69,7 +69,11 @@ If the server starts before MCP tools are initialized (e.g., in LangGraph Studio
### Cache invalidation
The MCP tools cache tracks the modification time (`mtime`) of `extensions_config.json`. When the file changes — for example, when a server is enabled or disabled through the Gateway API — the cache is marked stale and tools are reloaded on the next request.
The MCP tools cache tracks the resolved path and a `(mtime, size, sha256)` content signature of `extensions_config.json`. A changed signature is only a signal to re-check the effective MCP configuration: the cache compares the enabled servers' parsed configuration plus the global `mcpInterceptors` list, and retires cached tools and pooled sessions only when that slice actually changed.
Skills-only or middleware-only edits therefore keep the current MCP tools and sessions, and so does a switch to another config file with an identical MCP configuration. Enabling, disabling, or reconfiguring a server reloads tools on the next request. When no MCP server is enabled, tool assembly retires any previously initialized cache and sessions without starting new tool discovery.
The Gateway's explicit **Reset MCP tools cache** action (`POST /api/mcp/cache/reset`) is separate from this automatic check and always clears the cache and pooled sessions.
This means MCP server changes take effect without restarting the DeerFlow server.

View File

@ -63,7 +63,11 @@ DeerFlow 服务器启动时调用 `initialize_mcp_tools()`。这连接到所有
### 缓存失效
MCP 工具缓存追踪 `extensions_config.json` 的修改时间(`mtime`)。当文件更改时——例如通过 Gateway API 启用或禁用服务器时——缓存被标记为过时,下次请求时重新加载工具。
MCP 工具缓存追踪 `extensions_config.json` 的解析路径和 `(mtime, size, sha256)` 内容签名。签名变化只是触发重新比对的信号:缓存会比较已启用服务器的解析后配置与全局 `mcpInterceptors` 列表,只有该有效切片真正变化时才会淘汰已缓存的工具和会话池。
因此仅修改 Skills 或 middleware 不会重建工具或关闭会话;切换到另一份 MCP 配置完全相同的配置文件同样保留会话。启用、禁用或修改某个服务器会在下次请求时重新加载工具。当没有启用任何 MCP 服务器时,工具组装会回收此前已初始化的缓存与会话,但不会发起新的工具发现。
Gateway 的显式 **重置 MCP 工具缓存**(`POST /api/mcp/cache/reset`)与此自动检查相互独立,始终清理缓存和会话池。
这意味着 MCP 服务器更改无需重启 DeerFlow 服务器即可生效。