mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 06:00:38 +00:00
* feat(mcp): propagate cache resets across workers Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * docs: keep MCP reset guidance within instruction budgets --------- Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
596 lines
27 KiB
Python
596 lines
27 KiB
Python
"""Cache for MCP tools to avoid repeated loading."""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import threading
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
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__)
|
||
|
||
_mcp_tools_cache: list[BaseTool] | None = None
|
||
_cache_initialized = False
|
||
_init_lock = threading.RLock() # Guards cache state transitions.
|
||
_init_condition = threading.Condition(_init_lock)
|
||
_initializing_generation: int | None = None
|
||
_cache_generation = 0
|
||
|
||
# Cache-invalidation key for the resolved extensions config file. We track the
|
||
# resolved path *and* a ``(mtime, size, sha256)`` content signature — via the
|
||
# shared ``deerflow.config.file_signature`` helper also used by
|
||
# ``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.
|
||
_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
|
||
|
||
# Signature of the shared-config reset marker observed by the published
|
||
# cache. ``POST /api/mcp/cache/reset`` advances this marker even when
|
||
# ``extensions_config.json`` itself is unchanged (the important case is a
|
||
# remote MCP server changing its ``tools/list`` response). Every worker reads
|
||
# the marker from the same writable config directory before returning cached
|
||
# tools, so a reset initiated in one process retires sessions in the others on
|
||
# their next lookup.
|
||
_cache_reset_marker_signature: _ConfigSignature | None = None
|
||
|
||
|
||
def _cache_reset_marker_path(config_path: Path) -> Path:
|
||
"""Return the shared reset marker colocated with the extensions config."""
|
||
path = Path(config_path)
|
||
target = path.resolve(strict=False) if path.is_symlink() else path
|
||
return target.parent / f".{target.name}.mcp-cache-reset.json"
|
||
|
||
|
||
def _current_cache_reset_marker_signature(config_path: Path | None) -> _ConfigSignature | None:
|
||
"""Return the current shared-reset marker signature, if one exists."""
|
||
if config_path is None:
|
||
return None
|
||
return _get_config_signature(_cache_reset_marker_path(config_path))
|
||
|
||
|
||
def _resolve_config_path() -> Path | None:
|
||
"""Resolve the extensions config file path, or ``None`` when unconfigured.
|
||
|
||
``ExtensionsConfig.resolve_config_path()`` raises ``FileNotFoundError``
|
||
when an explicit `config_path` or `DEER_FLOW_EXTENSIONS_CONFIG_PATH`
|
||
points at a file that does not exist. That is deliberate for callers that
|
||
load the config for actual use (e.g. ``ExtensionsConfig.from_file()`` via
|
||
``get_mcp_tools()``): an operator-asserted explicit path going missing is
|
||
a real misconfiguration and must be surfaced loudly.
|
||
|
||
This helper is not one of those callers — it only backs the cache's own
|
||
staleness check (``_is_cache_stale``, via ``_current_config_state``),
|
||
which runs on every ``get_cached_mcp_tools()`` call and just wants to know
|
||
whether the previously loaded config is still current. If the file behind
|
||
a previously-valid explicit/env-var path becomes unreadable later
|
||
(deleted mid-run, a Docker mount hiccup, ...), raising here would crash
|
||
every subsequent call to that hot per-request path instead of leaving the
|
||
cache serving its last-known-good MCP tools. So this wrapper catches that
|
||
specific failure and treats it the same as "unconfigured", matching
|
||
``_is_cache_stale()``'s existing fail-soft handling of a ``None`` config
|
||
state (see its docstring). Scoping the catch here — rather than making
|
||
``resolve_config_path()`` itself return ``None`` for every caller — keeps
|
||
the loud failure intact for callers that actually need the file.
|
||
"""
|
||
from deerflow.config.extensions_config import ExtensionsConfig
|
||
|
||
try:
|
||
return ExtensionsConfig.resolve_config_path()
|
||
except FileNotFoundError:
|
||
logger.debug(
|
||
"Extensions config path could not be resolved while checking MCP cache staleness; treating as unconfigured for this check.",
|
||
exc_info=True,
|
||
)
|
||
return None
|
||
|
||
|
||
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 _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 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
|
||
|
||
current_path, current_signature = _current_config_state()
|
||
|
||
# A reset marker is independent of configuration bytes. It exists so an
|
||
# administrator can refresh a remote server's changed ``tools/list`` in
|
||
# every Gateway worker without fabricating a config edit. Missing current
|
||
# config keeps the existing last-known-good behavior below: there is no
|
||
# reliable shared directory to consult in that state.
|
||
if current_path is not None:
|
||
current_reset_signature = _current_cache_reset_marker_signature(current_path)
|
||
if current_reset_signature != _cache_reset_marker_signature:
|
||
logger.info("Shared MCP cache reset generation changed; cache is stale")
|
||
return True
|
||
|
||
# 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:
|
||
# 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); re-checking the effective MCP configuration", _config_path, current_path)
|
||
|
||
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
|
||
|
||
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:
|
||
"""Wait for an in-flight initialization without binding to any event loop."""
|
||
with _init_condition:
|
||
_init_condition.wait_for(lambda: _cache_initialized or _initializing_generation != generation)
|
||
|
||
|
||
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
|
||
global _initializing_generation, _cache_generation, _mcp_config_snapshot, _initialized_without_config
|
||
global _cache_reset_marker_signature
|
||
|
||
while True:
|
||
with _init_condition:
|
||
if _cache_initialized:
|
||
logger.info("MCP tools already initialized")
|
||
return _mcp_tools_cache or []
|
||
|
||
if _initializing_generation is None:
|
||
claim_generation = _cache_generation
|
||
_initializing_generation = claim_generation
|
||
break
|
||
|
||
waiting_generation = _initializing_generation
|
||
|
||
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
|
||
loaded_reset_signature = None
|
||
post_reset_signature = None
|
||
init_succeeded = False
|
||
try:
|
||
logger.info("Initializing 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_reset_signature = _current_cache_reset_marker_signature(_resolve_config_path())
|
||
loaded_tools = await get_mcp_tools(extensions_config=loaded_config)
|
||
post_path, post_sig = _current_config_state()
|
||
post_reset_signature = _current_cache_reset_marker_signature(post_path)
|
||
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:
|
||
with _init_condition:
|
||
if _initializing_generation == claim_generation:
|
||
_initializing_generation = None
|
||
_init_condition.notify_all()
|
||
|
||
retired_pool = None
|
||
with _init_condition:
|
||
try:
|
||
if _cache_generation != claim_generation:
|
||
logger.info("MCP cache was reset during initialization; discarding stale result")
|
||
return []
|
||
|
||
publish = loaded_snapshot is not None and post_snapshot is not None and loaded_snapshot == post_snapshot and loaded_reset_signature == post_reset_signature
|
||
if not publish:
|
||
logger.warning("MCP config or shared reset generation 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
|
||
_cache_reset_marker_signature = post_reset_signature
|
||
logger.info("MCP tools initialized: %d tool(s) loaded (config path: %s)", len(_mcp_tools_cache), _config_path)
|
||
return _mcp_tools_cache
|
||
finally:
|
||
if _initializing_generation == claim_generation:
|
||
_initializing_generation = None
|
||
_init_condition.notify_all()
|
||
|
||
if retired_pool is not None:
|
||
retired_pool.close_all_sync()
|
||
return []
|
||
|
||
|
||
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.
|
||
"""
|
||
while True:
|
||
retired_pool = None
|
||
with _init_lock:
|
||
if _is_cache_stale():
|
||
logger.info("MCP cache is stale, resetting for re-initialization...")
|
||
retired_pool = _reset_mcp_tools_cache_state_and_retire_pool_locked()
|
||
|
||
if _cache_initialized:
|
||
return _mcp_tools_cache or []
|
||
|
||
if _initializing_generation is not None:
|
||
_init_condition.wait_for(lambda: _initializing_generation is None or _cache_initialized)
|
||
continue
|
||
|
||
if retired_pool is not None:
|
||
retired_pool.close_all_sync()
|
||
|
||
logger.info("MCP tools not initialized, performing lazy initialization...")
|
||
# Only ``get_event_loop()`` may fall back to ``asyncio.run``: a
|
||
# ``RuntimeError`` raised *by* ``initialize_mcp_tools()`` (for example
|
||
# ``McpTaskConfigurationError``) must not trigger a second discovery
|
||
# pass that respawns every stdio server and re-fetches OAuth tokens.
|
||
try:
|
||
loop: asyncio.AbstractEventLoop | None = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = None
|
||
try:
|
||
if loop is None or loop.is_closed():
|
||
asyncio.run(initialize_mcp_tools())
|
||
elif loop.is_running():
|
||
import concurrent.futures
|
||
|
||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||
future = executor.submit(asyncio.run, initialize_mcp_tools())
|
||
future.result()
|
||
else:
|
||
loop.run_until_complete(initialize_mcp_tools())
|
||
except Exception:
|
||
logger.exception("Failed to lazy-initialize MCP tools")
|
||
return []
|
||
|
||
with _init_lock:
|
||
if _cache_initialized:
|
||
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, _mcp_config_snapshot, _initialized_without_config
|
||
global _cache_reset_marker_signature
|
||
|
||
_mcp_tools_cache = None
|
||
_cache_initialized = False
|
||
_config_path = None
|
||
_config_signature = None
|
||
_mcp_config_snapshot = None
|
||
_initialized_without_config = False
|
||
_cache_reset_marker_signature = None
|
||
_cache_generation += 1
|
||
_init_condition.notify_all()
|
||
|
||
|
||
def _reset_mcp_tools_cache_state_and_retire_pool_locked():
|
||
"""Retire the MCP session pool and reset cache state under one lock.
|
||
|
||
Tool wrappers close over the module-level session-pool singleton when they
|
||
are built. Any path that invalidates the tool cache must therefore swap the
|
||
singleton before waiters/fresh initializers can rebuild wrappers, including
|
||
automatic config-signature invalidation in ``get_cached_mcp_tools()``.
|
||
"""
|
||
from deerflow.mcp.session_pool import reset_session_pool
|
||
|
||
retired_pool = reset_session_pool()
|
||
_reset_mcp_tools_cache_state()
|
||
return retired_pool
|
||
|
||
|
||
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.
|
||
"""
|
||
# 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 reset_session_pool
|
||
|
||
with _init_condition:
|
||
# Retire the session-pool singleton before cache waiters can start a
|
||
# fresh initialization. Otherwise a concurrent initializer can build
|
||
# tool wrappers against the soon-to-be-detached pool and publish
|
||
# them after this reset replaces the singleton.
|
||
retired_pool = reset_session_pool()
|
||
_reset_mcp_tools_cache_state()
|
||
|
||
if retired_pool is not None:
|
||
retired_pool.close_all_sync()
|
||
except Exception:
|
||
logger.debug("Could not close MCP session pool on cache reset", exc_info=True)
|
||
|
||
logger.info("MCP tools cache reset")
|
||
|
||
|
||
def publish_mcp_tools_cache_reset() -> str | None:
|
||
"""Publish a shared-config reset generation, then retire local MCP state.
|
||
|
||
The marker is written next to ``extensions_config.json`` because that file
|
||
is already the runtime-editable directory shared by workers that consume
|
||
the same config. A random generation avoids read-modify-write counters and
|
||
cannot lose two concurrent reset requests: the final atomic write still
|
||
differs from every worker's previously observed signature.
|
||
|
||
Returns:
|
||
The published generation, or ``None`` when no shared config path can be
|
||
resolved and the operation therefore falls back to a process-local
|
||
reset.
|
||
"""
|
||
config_path = _resolve_config_path()
|
||
if config_path is None:
|
||
reset_mcp_tools_cache()
|
||
return None
|
||
|
||
from deerflow.config.extensions_config import (
|
||
atomic_write_extensions_config,
|
||
extensions_config_file_lock,
|
||
extensions_config_write_lock,
|
||
)
|
||
|
||
generation = uuid.uuid4().hex
|
||
marker_path = _cache_reset_marker_path(config_path)
|
||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||
atomic_write_extensions_config(
|
||
marker_path,
|
||
{"version": 1, "generation": generation},
|
||
)
|
||
|
||
# Publish-before-retire is intentional. A successful API response must
|
||
# never mean only the handling worker was refreshed; if publication fails,
|
||
# the exception propagates and the local cache remains intact for a safe,
|
||
# idempotent retry.
|
||
reset_mcp_tools_cache()
|
||
return generation
|