mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-15 17:28:40 +00:00
* fix(mcp): invalidate tools cache on config content + path, not just newer mtime The MCP tools cache invalidated only on a strict extensions-config mtime `>` comparison and tracked no resolved config path, so `_is_cache_stale()` missed: - content changes with an unchanged mtime (same-second edits; object-store / network mounts that do not bump mtime); - content changes with a backward mtime (git checkout, cp -p / backup restore, tar / rsync preserving timestamps); - a resolved-path switch to a different config file with mtime <= the recorded value (structurally invisible — no path was tracked at all). On multi-worker (uvicorn/gunicorn) or stale-mtime deployments this leaves the LangGraph-embedded runtime and every non-writer worker serving stale MCP tools after `PUT /api/mcp/config`, breaking the module's documented promise that changes made through the Gateway API are reflected in the embedded runtime. Record the resolved config path and a `(mtime, size, sha256)` content signature at initialization and invalidate when the path OR the signature differs (`!=`), mirroring `config/app_config.py::get_app_config()` so the two runtime-editable config files share one content-based staleness signal. The per-call stat was already paid, and the small-file sha256 matches the cost app_config pays per request. The "config missing / not yet initialized" no-op behavior and the cache reset endpoint are preserved. Adds backend/tests/test_mcp_cache.py covering all three failure modes plus unchanged-file and forward-edit sanity cases. Also folds in an incidental backend/AGENTS.md doc-sync: the restart-required field list was missing `scheduler` and `run_ownership`, both present in reload_boundary.py::STARTUP_ONLY_FIELDS. * test(mcp): pin cache staleness contracts raised in review Two review observations on the content-signature cache fix, both raised as non-blocking design questions rather than bugs: - Whether relying on mtime+size alone (skipping the sha256) could ever be "optimized" back in, reopening the narrow same-second / identical-length swap gap the signature was built to close. - Whether the extensions config being deleted entirely after a successful init leaves the cache in a defined state, since current_signature flips to None and _is_cache_stale() returns False. Neither is a behavior change: both were already the intended contract, preserved verbatim from the pre-fix mtime-only code (which also returned False once the file could no longer be stat-ed). Record the reasoning inline and add regression tests that pin each contract so a future change cannot alter either silently: - test_same_mtime_same_size_swap_is_stale: a same-length server-name swap that leaves mtime AND size unchanged (the precise scenario from review, sharper than the existing same-mtime test, which also changes size) is still caught only because the sha256 is computed unconditionally. - test_config_deleted_after_init_is_not_stale: deleting the config file after init keeps the cache serving its last-known-good MCP tools instead of invalidating into an unconfigured state. Both new tests were confirmed to fail against a deliberately reintroduced version of the regression they guard (hash short-circuit / removed None-guard), then confirmed to pass against the real code.
227 lines
9.3 KiB
Python
227 lines
9.3 KiB
Python
"""Cache for MCP tools to avoid repeated loading."""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from langchain_core.tools import BaseTool
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_mcp_tools_cache: list[BaseTool] | None = None
|
||
_cache_initialized = False
|
||
_initialization_lock = asyncio.Lock()
|
||
|
||
# Cache-invalidation key for the resolved extensions config file. We track the
|
||
# resolved path *and* a ``(mtime, size, sha256)`` content signature — mirroring
|
||
# ``deerflow.config.app_config`` for the sibling runtime-editable config file —
|
||
# rather than only the mtime. A strict mtime ``>`` comparison misses same-second
|
||
# edits and mtime that stays put or moves backward (object-store / network
|
||
# mounts, ``git checkout``, ``cp -p`` / backup restore, ``tar`` / ``rsync`` that
|
||
# preserve timestamps), and tracking no path at all makes a switch to a
|
||
# different config file with an equal-or-older mtime structurally invisible.
|
||
_ConfigSignature = tuple[float | None, int | None, str | None]
|
||
_config_path: Path | None = None # Resolved extensions config path at init time
|
||
_config_signature: _ConfigSignature | None = None # (mtime, size, sha256) at init time
|
||
|
||
|
||
def _resolve_config_path() -> Path | None:
|
||
"""Resolve the extensions config file path, or ``None`` when unconfigured."""
|
||
from deerflow.config.extensions_config import ExtensionsConfig
|
||
|
||
return ExtensionsConfig.resolve_config_path()
|
||
|
||
|
||
def _get_config_signature(config_path: Path) -> _ConfigSignature | None:
|
||
"""Get cache metadata for the extensions config file, including a content digest.
|
||
|
||
Mirrors ``deerflow.config.app_config._get_config_signature`` so both
|
||
runtime-editable config files (``config.yaml`` and ``extensions_config.json``)
|
||
share the same content-based staleness signal. Returns ``None`` when the
|
||
file cannot be stat-ed (e.g. it does not exist).
|
||
"""
|
||
try:
|
||
stat_result = config_path.stat()
|
||
except OSError:
|
||
return None
|
||
|
||
# Always hash the full file here rather than short-circuiting when
|
||
# mtime/size already match a previously recorded signature: swapping in a
|
||
# different MCP server config of identical byte length within the same
|
||
# second leaves mtime *and* size unchanged, so only the sha256 catches
|
||
# that swap. Skipping the hash on an mtime/size match would reopen the
|
||
# narrow gap this signature was built to close.
|
||
digest = hashlib.sha256()
|
||
try:
|
||
with config_path.open("rb") as f:
|
||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
except OSError:
|
||
return (stat_result.st_mtime, stat_result.st_size, None)
|
||
|
||
return (stat_result.st_mtime, stat_result.st_size, digest.hexdigest())
|
||
|
||
|
||
def _current_config_state() -> tuple[Path | None, _ConfigSignature | None]:
|
||
"""Return the currently resolved extensions config path and its signature."""
|
||
config_path = _resolve_config_path()
|
||
if config_path is None:
|
||
return None, None
|
||
return config_path, _get_config_signature(config_path)
|
||
|
||
|
||
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.
|
||
|
||
Returns:
|
||
True if the cache should be invalidated, False otherwise.
|
||
"""
|
||
if not _cache_initialized:
|
||
return False # Not initialized yet, not stale
|
||
|
||
current_path, current_signature = _current_config_state()
|
||
|
||
# Preserve the original "config missing / not yet recorded" behavior: if
|
||
# there was no readable config when the cache was populated, or there is
|
||
# none now, do not invalidate. This also covers the config being deleted
|
||
# entirely after a successful init (current_signature flips to None): the
|
||
# cache intentionally keeps serving its last-known-good MCP tools rather
|
||
# than invalidating into an unconfigured state, matching the pre-fix
|
||
# mtime-only contract (which also returned False once the file could no
|
||
# 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:
|
||
return False
|
||
|
||
if current_path != _config_path:
|
||
logger.info("MCP config path changed (%s -> %s), cache is stale", _config_path, current_path)
|
||
return True
|
||
|
||
if current_signature != _config_signature:
|
||
logger.info("MCP config content changed (signature %s -> %s), cache is stale", _config_signature, current_signature)
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
async def initialize_mcp_tools() -> list[BaseTool]:
|
||
"""Initialize and cache MCP tools.
|
||
|
||
This should be called once at application startup.
|
||
|
||
Returns:
|
||
List of LangChain tools from all enabled MCP servers.
|
||
"""
|
||
global _mcp_tools_cache, _cache_initialized, _config_path, _config_signature
|
||
|
||
async with _initialization_lock:
|
||
if _cache_initialized:
|
||
logger.info("MCP tools already initialized")
|
||
return _mcp_tools_cache or []
|
||
|
||
from deerflow.mcp.tools import get_mcp_tools
|
||
|
||
logger.info("Initializing MCP tools...")
|
||
_mcp_tools_cache = await get_mcp_tools()
|
||
_cache_initialized = True
|
||
_config_path, _config_signature = _current_config_state() # Record config path + content signature
|
||
logger.info("MCP tools initialized: %d tool(s) loaded (config path: %s)", len(_mcp_tools_cache), _config_path)
|
||
|
||
return _mcp_tools_cache
|
||
|
||
|
||
def get_cached_mcp_tools() -> list[BaseTool]:
|
||
"""Get cached MCP tools with lazy initialization.
|
||
|
||
If tools are not initialized, automatically initializes them.
|
||
This ensures MCP tools work in both FastAPI and LangGraph Studio contexts.
|
||
|
||
Also checks if the config file has been modified since last initialization,
|
||
and re-initializes if needed. This ensures that changes made through the
|
||
Gateway API are reflected in the Gateway-embedded LangGraph runtime.
|
||
|
||
Returns:
|
||
List of cached MCP tools.
|
||
"""
|
||
global _cache_initialized
|
||
|
||
# Check if cache is stale due to config file changes
|
||
if _is_cache_stale():
|
||
logger.info("MCP cache is stale, resetting for re-initialization...")
|
||
reset_mcp_tools_cache()
|
||
|
||
if not _cache_initialized:
|
||
logger.info("MCP tools not initialized, performing lazy initialization...")
|
||
try:
|
||
# Try to initialize in the current event loop
|
||
loop = asyncio.get_event_loop()
|
||
if loop.is_running():
|
||
# If loop is already running (e.g., in LangGraph Studio),
|
||
# we need to create a new loop in a thread
|
||
import concurrent.futures
|
||
|
||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||
future = executor.submit(asyncio.run, initialize_mcp_tools())
|
||
future.result()
|
||
else:
|
||
# If no loop is running, we can use the current loop
|
||
loop.run_until_complete(initialize_mcp_tools())
|
||
except RuntimeError:
|
||
# No event loop exists, create one
|
||
try:
|
||
asyncio.run(initialize_mcp_tools())
|
||
except Exception:
|
||
logger.exception("Failed to lazy-initialize MCP tools")
|
||
return []
|
||
except Exception:
|
||
logger.exception("Failed to lazy-initialize MCP tools")
|
||
return []
|
||
|
||
return _mcp_tools_cache or []
|
||
|
||
|
||
def reset_mcp_tools_cache() -> None:
|
||
"""Reset the MCP tools cache.
|
||
|
||
This is useful for testing or when you want to reload MCP tools.
|
||
Also closes all persistent MCP sessions so they are recreated on
|
||
the next tool load.
|
||
"""
|
||
global _mcp_tools_cache, _cache_initialized, _config_path, _config_signature
|
||
_mcp_tools_cache = None
|
||
_cache_initialized = False
|
||
_config_path = None
|
||
_config_signature = None
|
||
|
||
# Close persistent sessions – they will be recreated by the next
|
||
# get_mcp_tools() call with the (possibly updated) connection config.
|
||
#
|
||
# close_all_sync() already picks the correct strategy per owning loop:
|
||
# * sessions owned by the *current* running loop are only *signalled*
|
||
# (their owner task runs __aexit__ once the loop regains control –
|
||
# this is correct and leak-free, since the loop keeps the task alive),
|
||
# * sessions on other threads' loops are torn down deterministically,
|
||
# * idle/closed loops are handled or skipped.
|
||
# We deliberately do NOT try to synchronously wait for the current running
|
||
# loop to finish teardown here: that is a self-deadlock (the loop can only
|
||
# run the teardown after this synchronous call returns control to it).
|
||
try:
|
||
from deerflow.mcp.session_pool import get_session_pool
|
||
|
||
get_session_pool().close_all_sync()
|
||
except Exception:
|
||
logger.debug("Could not close MCP session pool on cache reset", exc_info=True)
|
||
|
||
from deerflow.mcp.session_pool import reset_session_pool
|
||
|
||
reset_session_pool()
|
||
logger.info("MCP tools cache reset")
|