spud eadc1e230e
feat(mcp): reuse unchanged per-server tool discovery results (#6795)
* feat(mcp): add per-server discovery cache scaffolding

Introduce the per-server tool cache entry, canonical parsed-config snapshots on the applied revision, and the shared _binding_matches reuse predicate. Identity reuse is object-identity based on the pool's current binding (no value equality); snapshots and ownership handles stay out of repr. No selection, eviction or publication is wired yet.

* feat(mcp): selectively evict stale per-server tool cache entries

Evict per-server discovery results during selective reconciliation, after the new binding epoch is installed, so unchanged servers keep their exact entry objects while changed, removed or re-bound servers (including lifecycle-token rotations) are dropped. Delegate the binding verdict to _binding_matches so reuse and eviction cannot diverge.

Clear _server_tool_cache on every full reset, and count retained entries in the reconciliation state predicates so a lingering entry can never let a later config change be silently skipped.

* feat(mcp): reuse unchanged per-server results during initialization

Initialize MCP tools from per-server discovery instead of a single full pass: select reusable entries under the claim lock, discover only the missing servers with get_mcp_tools_by_server, then publish atomically after re-validating every candidate binding against the pool's current binding via _binding_matches. Failed servers stay absent (never cached as empty), and superseded or partially bound candidates are discarded whole. Test fixtures now drive the grouped entry point.

* fix(mcp): close T4 review gaps in per-server cache publication

Address four findings from the adversarial review: treat retained entries as unverifiable when the applied baseline is None so an interceptor change still forces a full reset; count retained entries in the claim-time shared-reset predicate; refuse to publish a false-success empty cache when every enabled server failed discovery; and hoist the lazy imports inside the initializer's try/finally so an import failure releases the initialization claim.

* fix(mcp): sanitize cache failure logging and document per-server cache

The outer lazy-initialization handler now logs only the exception type, never the raw message or traceback, so a discovery error cannot print a resolved $VAR credential. The committed-writer planner mirrors the reader's no-baseline guard so retained entries are never reused under an unknown interceptor chain. Document the per-server tool cache (reuse, invalidation, failure handling, remote tool changes) in MCP_SERVER.md and mcp/AGENTS.md, and drop phase-staging wording from the new code and tests.

* docs(mcp): keep the agent-guidance chain under its hard limit

F2's cache notes pushed the effective AGENTS.md instruction chain 905 bytes past its 98304-byte hard limit, failing the agent-guidance CI job. Compress the selective-reconciliation bullet to its invariants (the full cache behaviour lives in docs/MCP_SERVER.md), and reword the total-failure comment so it no longer implies that build-invalid connections were attempted.

* fix(mcp): fence cached tools by interceptor chain

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 08:44:21 +08:00

1408 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cache for MCP tools to avoid repeated loading."""
import asyncio
import json
import logging
import threading
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
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.config.shared_reset_marker import SharedResetMarker
from deerflow.mcp.config_normalization import normalize_mcp_interceptor_paths, normalize_mcp_server_config
from deerflow.mcp.lifecycle import (
InvalidMcpLifecycle,
_stdio_fingerprints,
effective_tokens,
lifecycle_delta,
parse_mcp_lifecycle,
)
from deerflow.mcp.tools import ServerDiscoveryResult
if TYPE_CHECKING:
from deerflow.mcp.session_pool import MCPSessionPool
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class _AppliedMcpRevision:
"""Effective MCP revision whose stdio binding epochs have been applied.
``effective_snapshot`` is the same string
``_effective_mcp_config_snapshot`` produces, so it is directly comparable
with the published baseline. ``interceptors`` isolates the global
``mcpInterceptors`` chain, because an interceptor change is allowed to fall
back to the conservative full reset instead of a selective one.
``stdio_connections`` maps every enabled stdio server to
``normalized_connection_fingerprint(build_server_params(...))`` — the exact
digest ``ensure_binding``/``reconcile_bindings`` consume. Those values
are one-way digests, so resolved ``$VAR`` credentials never appear there.
``server_snapshots`` maps every enabled server to
``_server_config_snapshot(...)`` — a canonical digest of its *parsed*
configuration, so a metadata/routing change is distinguishable from an
equivalent ``type``/``transport`` respelling. It drives selective eviction;
it is never used as connection identity (``stdio_connections`` owns that).
``effective_snapshot`` and ``server_snapshots`` are *not* secret-safe (they
are the fully resolved MCP slice), so they are excluded from ``repr`` — the
same contract ``_mcp_config_snapshot`` documents. Never log or persist these
fields.
"""
effective_snapshot: str = field(repr=False)
interceptors: str
stdio_connections: Mapping[str, str]
lifecycle_tokens: Mapping[str, str]
server_snapshots: Mapping[str, str] = field(repr=False)
# Reconciliation kinds produced by ``_plan_mcp_reconciliation_locked``. The
# "effective MCP configuration unchanged" case has no plan at all: it only
# adopts the new path/signature, matching the long-standing ``_is_cache_stale``
# contract for skills-only edits and equivalent path switches.
_RECONCILE_SELECTIVE = "selective"
_RECONCILE_FULL = "full"
@dataclass(frozen=True, slots=True)
class _McpReconciliation:
"""How one observed configuration state should be applied.
``selective`` carries the observed revision; ``_apply_mcp_reconciliation_locked``
derives the changed/removed server set by diffing it against the applied
baseline, so the classification and the epoch installation always see the
same revision.
"""
kind: str
revision: _AppliedMcpRevision | None = None
path: Path | None = None
signature: _ConfigSignature | None = None
retire_unlisted: bool = False
force_rebind: frozenset[str] = frozenset()
@dataclass(frozen=True, slots=True)
class McpReconciliationPending:
"""State a committed transition retired outside the cache critical section."""
retired_pool: "MCPSessionPool | None" = None
@dataclass(frozen=True, slots=True)
class _ServerToolCacheEntry:
"""A published per-server discovery result and its config identity.
``snapshot`` is the server's canonical config identity at publication (see
``_server_config_snapshot``), ``interceptors`` records the normalized global
chain used to wrap the tools, and ``result`` is the exact
``ServerDiscoveryResult`` published for it. All are excluded from ``repr``:
the snapshot may embed resolved credentials and the result carries ownership
handles.
"""
snapshot: str = field(repr=False)
interceptors: str = field(repr=False)
result: ServerDiscoveryResult = field(repr=False)
_mcp_tools_cache: list[BaseTool] | None = None
# Per-server published discovery results, keyed by server name. Deliberately
# separate from the flattened ``_mcp_tools_cache`` so an unchanged server's exact
# ``BaseTool`` objects can be reused without re-running discovery. The
# reconciliation and initialization paths populate and selectively evict it.
_server_tool_cache: dict[str, _ServerToolCacheEntry] = {}
_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
# Process-local reconciliation baseline: the effective MCP revision whose stdio
# bindings this process has already installed into the *current* pool singleton.
# It is deliberately separate from ``_mcp_config_snapshot`` (which records the
# revision the *published* tools were built from). The tool cache can be empty
# or have an in-flight discovery while the binding epoch is already advanced:
#
# A(v1) tools published, applied = v1
# -> A(v2) observed: install the v2 epoch, drop the v1 tools
# -> A(v2) discovery still running
# -> A(v3) observed: reconciliation must continue from *applied v2*, not
# from the now-empty published baseline, or the second change is lost.
#
# A claim *installs* the revision it records (see
# ``_install_claimed_revision_locked``) rather than merely recording one handed
# to discovery, so this baseline means "these deployment epochs are in place".
# It is recorded when discovery starts, so a discovery that fails after seeding
# a binding still leaves a baseline that can retire the residual server.
_applied_mcp_revision: _AppliedMcpRevision | None = None
#: ``.<extensions config name>.mcp-cache-reset.json`` beside the resolved config.
#: The generic marker/tracker contract lives in ``deerflow.config.shared_reset_marker``
#: and is shared with the skills prompt-cache reset.
MCP_CACHE_RESET_MARKER = SharedResetMarker("mcp-cache-reset")
def _cache_reset_marker_path(config_path: Path) -> Path:
"""Return the shared reset marker colocated with the extensions config."""
return MCP_CACHE_RESET_MARKER.path_for(config_path)
def _current_cache_reset_marker_signature(config_path: Path | None) -> _ConfigSignature | None:
"""Return the current shared-reset marker signature, if one exists."""
return MCP_CACHE_RESET_MARKER.current_signature(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 _interceptors_snapshot(config) -> str:
"""Serialize the normalized global interceptor chain for classification.
Mirrors what ``_effective_mcp_config_snapshot`` embeds so a bare string and
its single-element list stay equivalent, while an order/identity change
still compares unequal.
"""
return json.dumps(
normalize_mcp_interceptor_paths((config.model_extra or {}).get("mcpInterceptors")),
sort_keys=True,
ensure_ascii=False,
)
def _server_config_snapshot(server) -> str:
"""Canonical, comparable identity for one parsed MCP server config.
Built from ``normalize_mcp_server_config`` so an equivalent
``type``/``transport`` spelling and differing JSON key order compare equal,
while any field that can affect the discovered tools (connection params,
``description``, ``routing``, ``tools``, ``tool_name_prefix``, ...) compares
unequal. ``sort_keys`` normalizes nested key order.
The result may embed resolved ``$VAR`` credentials; it is only ever compared
for equality and must never be logged or persisted.
"""
return json.dumps(normalize_mcp_server_config(server), sort_keys=True, ensure_ascii=False)
def _mcp_revision_from_config(config) -> _AppliedMcpRevision:
"""Build the applied-reconciliation revision for a parsed config.
Connection identity is derived through ``build_server_params`` +
``normalized_connection_fingerprint`` — never a ``model_dump()`` of the whole
server — so presentation-only fields (``description``/``routing``/``tools``/
``tool_name_prefix``) cannot masquerade as a connection change.
"""
stdio_connections = _stdio_fingerprints(config)
ledger = parse_mcp_lifecycle(config.model_extra or {})
server_snapshots = {name: _server_config_snapshot(server) for name, server in config.get_enabled_mcp_servers().items()}
return _AppliedMcpRevision(
effective_snapshot=_effective_mcp_config_snapshot(config),
interceptors=_interceptors_snapshot(config),
stdio_connections=stdio_connections,
lifecycle_tokens=effective_tokens(config, ledger),
server_snapshots=server_snapshots,
)
def _derived_applied_revision(config) -> _AppliedMcpRevision | None:
"""Best-effort baseline for a config discovery is about to run against."""
try:
return _mcp_revision_from_config(config)
except Exception:
logger.warning(
"Could not derive the MCP connection-identity baseline for the current revision; a later change will fall back to a conservative reset",
)
return None
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 _load_stable_mcp_config(config_path: Path, expected_signature: _ConfigSignature):
"""Parse the config only if it is attributable to a single stable revision.
``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 state 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 config
def _read_stable_mcp_snapshot(config_path: Path, expected_signature: _ConfigSignature) -> str | None:
"""Return the effective MCP snapshot of a stable, verifiable revision."""
config = _load_stable_mcp_config(config_path, expected_signature)
if config is None:
return None
return _effective_mcp_config_snapshot(config)
def _read_stable_mcp_revision(
config_path: Path,
expected_signature: _ConfigSignature,
) -> tuple[object, _AppliedMcpRevision] | None:
"""Return the parsed config and reconciliation revision of a stable revision.
The caller needs the parsed instance (not just the derived fingerprint map)
to ask the frozen durable-task snapshot whether this revision may be applied.
"""
config = _load_stable_mcp_config(config_path, expected_signature)
if config is None:
return None
try:
revision = _mcp_revision_from_config(config)
except InvalidMcpLifecycle:
logger.warning("Extensions config mcpLifecycle is present but invalid; treating the MCP cache as stale")
return None
return config, revision
def _frozen_task_snapshot_rejects(config):
"""Return the rejection a frozen durable-task snapshot raises for ``config``.
Durable background calls resolve their connection from the configuration
frozen at Gateway startup, so installing a newer revision's binding epoch
fences every status/cancel call with ``StaleMCPBindingError`` even though hot
reload of that server is correctly rejected. Both decisions that install an
epoch must therefore consult this first — the reconciliation planner falls
back to the conservative full reset, and a discovery claim fails exactly as
``get_mcp_tools()`` would, without touching the pool.
"""
from deerflow.mcp.tasks.runtime import McpTaskConfigurationError, validate_mcp_task_config_snapshot
try:
validate_mcp_task_config_snapshot(config)
except McpTaskConfigurationError as rejection:
return rejection
return None
def _deployment_binding_delta(
previous: _AppliedMcpRevision | None,
revision: _AppliedMcpRevision,
) -> tuple[dict[str, str], tuple[str, ...]]:
"""Return ``(active, removed)`` deployment bindings for ``revision``.
``removed`` comes from the previously applied revision, so a server that
disappeared since then gets a tombstone while every server still present with
the same fingerprint keeps its epoch and session. With no applied baseline —
the first claim in a process, or a revision whose baseline could not be
derived — the reconciliation additionally retires every server the pool still
holds deployment state for that this revision does not declare; that
enumeration happens inside the pool's own lock (``retire_unlisted``) so a
concurrent durable-task binding cannot slip past it. Both the classified
transition and the discovery claim use this one computation, so their notion
of "what changed" cannot drift.
"""
active = dict(revision.stdio_connections)
previous_connections = previous.stdio_connections if previous is not None else {}
removed = set(previous_connections) - set(active)
return active, tuple(sorted(removed))
def _binding_matches(
result: ServerDiscoveryResult,
name: str,
fingerprint: str | None,
pool: "MCPSessionPool | None",
) -> bool:
"""Return whether ``result`` may be reused for ``name`` against ``pool``.
``fingerprint`` is the server's *current* connection digest: ``None`` for
HTTP/SSE servers, which hold no pooled stdio binding.
For stdio, reuse requires the *exact* binding object to still be the pool's
current one. ``ServerBinding`` is declared ``eq=False`` precisely so this
check is object identity, never dataclass value equality: a cross-worker
delete/re-add of an identical server re-epochs the binding to a new object
carrying the same fingerprint, and only identity comparison rejects the
superseded result (the ABA case).
This deliberately calls the pool's private ``_binding_is_current`` — an
intentional, read-only, in-package coupling shared by the reuse check and the
pre-publication check so the two cannot diverge. Callers must already hold
``_init_condition``; ``_binding_is_current`` then acquires the pool lock,
preserving the established ``_init_condition -> pool._lock`` order.
"""
if fingerprint is None: # HTTP/SSE: no pooled stdio binding may be attached.
return result.pool is None and result.binding is None
binding = result.binding
return pool is not None and result.pool is pool and binding is not None and binding.domain == "deployment" and binding.server_name == name and binding.fingerprint == fingerprint and pool._binding_is_current(binding)
def _evict_server_tool_entries_locked(revision: _AppliedMcpRevision, pool: "MCPSessionPool") -> None:
"""Drop per-server results a newly applied revision no longer justifies.
Caller must hold ``_init_condition``. This runs *after* the caller has
installed the new binding epoch (see ``_apply_mcp_reconciliation_locked``),
so a lifecycle-token rotation that leaves the connection fingerprint
untouched has already replaced the binding object and is caught by the
identity check below. Retained entries keep their exact identity so an
unchanged server's published objects are reused verbatim.
An entry survives only when both hold:
* its canonical config snapshot equals the new revision's
(``stdio_connections`` alone misses presentation-only changes), and
* ``_binding_matches`` proves the result still belongs to the pool's current
deployment binding (HTTP/SSE: no pooled binding at all).
Names absent from ``revision.server_snapshots`` (deleted, disabled or never
declared) are evicted because ``.get`` returns ``None``. The binding verdict
is delegated to ``_binding_matches`` so the reuse and eviction paths cannot
diverge.
"""
snapshots = revision.server_snapshots
stale = [name for name, entry in _server_tool_cache.items() if snapshots.get(name) != entry.snapshot or not _binding_matches(entry.result, name, revision.stdio_connections.get(name), pool)]
for name in stale:
del _server_tool_cache[name]
def _install_claimed_revision_locked(pool, revision: _AppliedMcpRevision, *, previous: _AppliedMcpRevision | None) -> None:
"""Install ``revision``'s deployment epochs before discovery runs.
Recording an applied baseline must mean the epochs are actually in place, not
merely that discovery was handed this revision. A durable-task caller or an
earlier failed discovery can already hold an older binding for the same
server, and ``ensure_binding()`` fails closed on a fingerprint mismatch, so a
claim that only *recorded* the revision would leave the cache permanently
unable to publish. Reconciling here is idempotent for unchanged servers
(their sessions survive) and is scoped to the deployment domain, so a
deployment revision never retires a personal binding or session. Without a
previous baseline the pool itself reports which unlisted deployment servers
the claim must tombstone, under the same lock that installs the epochs.
"""
active, removed = _deployment_binding_delta(previous, revision)
if previous is None:
force_rebind: frozenset[str] = frozenset(active)
else:
force_rebind = lifecycle_delta(previous.lifecycle_tokens, revision.lifecycle_tokens, active)
pool.reconcile_bindings(
active,
removed,
domain="deployment",
retire_unlisted=previous is None,
force_rebind=force_rebind,
)
def _shared_reset_has_local_state_locked(pool) -> bool:
"""True when an unapplied shared reset generation still has state to retire.
Published tools and an applied baseline both count. So does deployment state
the pool still holds: a durable-task caller can bind a server and run a
persistent session before this process ever publishes a cache, and such a
session must not survive an explicit shared reset. Personal-domain state alone
is deliberately not a reason — a deployment-side reset must not retire it.
Caller must hold ``_init_condition``.
"""
return _cache_initialized or _applied_mcp_revision is not None or bool(_server_tool_cache) or bool(pool.retained_server_names(domain="deployment"))
def _has_mcp_reconciliation_state() -> bool:
"""True when this process holds state a config observation could affect.
Published tools, a recorded applied revision, an in-flight discovery and a
retained per-server entry are all reasons to keep watching the config. The
entry check matters because ``initialize_mcp_tools`` can null the applied
baseline (an unbuildable revision) without clearing retained entries; without
counting them, a later config change would be silently skipped. A process that
never initialized MCP pays no config-read cost
(``refresh_mcp_cache_if_active`` contract).
"""
from deerflow.mcp.session_pool import current_session_pool
pool = current_session_pool()
return _cache_initialized or _applied_mcp_revision is not None or _initializing_generation is not None or bool(_server_tool_cache) or (pool is not None and bool(pool.retained_server_names(domain="deployment")))
def _plan_mcp_reconciliation_locked() -> _McpReconciliation | None:
"""Classify the currently observed MCP configuration against applied state.
Returns ``None`` when there is nothing to apply, a ``selective`` plan when
the effective MCP slice changed without touching the global interceptor
chain, and a ``full`` plan for the conservative cases (shared reset marker
change, interceptor change, unreadable/unverifiable config, or no applied
baseline to diff against).
The "effective MCP configuration did not change" case also returns ``None``
but first adopts the new path/signature, preserving the long-standing
``_is_cache_stale`` contract for skills-only edits and equivalent path
switches. Caller must hold ``_init_condition`` (which serialises
classification and application, so two readers can never apply revisions
out of order).
"""
global _applied_mcp_revision, _config_path, _config_signature
if not _has_mcp_reconciliation_state():
return None
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 _McpReconciliation(_RECONCILE_FULL)
# Fail-soft: a config that disappears after a successful load keeps serving
# its last-known-good MCP tools instead of tearing MCP down into an
# unconfigured state (matching the pre-fix mtime-only contract). This is a
# deliberate choice — a future change that wants "config deleted" to tear
# down MCP servers needs its own explicit signal. A config that *appears*
# after an unconfigured initialization has a readable signature, so it
# skips this branch and is still picked up by the comparison below.
if current_signature is None or current_path is None:
return None
if not _signature_is_verifiable(current_signature):
logger.info("Extensions config signature has no content digest; treating the MCP cache as stale")
return _McpReconciliation(_RECONCILE_FULL)
if current_path == _config_path and current_signature == _config_signature:
return None
# 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)
loaded = _read_stable_mcp_revision(current_path, current_signature)
if loaded is None:
logger.info("Extensions config could not be read as a stable MCP revision; treating the MCP cache as stale")
return _McpReconciliation(_RECONCILE_FULL)
candidate_config, revision = loaded
applied = _applied_mcp_revision
force_rebind = lifecycle_delta(applied.lifecycle_tokens, revision.lifecycle_tokens, revision.stdio_connections) if applied is not None else frozenset()
if applied is not None and revision.effective_snapshot == applied.effective_snapshot and not force_rebind:
logger.info("Extensions config changed but the effective MCP configuration and active lifecycle generations did not; keeping cached MCP tools and sessions")
# Snapshot equality implies equal stdio_connections, so no active epoch
# changes; but a tombstone-only history advance must still be recorded.
# The published tool cache is deliberately left untouched.
_applied_mcp_revision = revision
_config_path, _config_signature = current_path, current_signature
return None
if applied is not None and revision.interceptors != applied.interceptors:
logger.info("MCP global interceptor chain changed; cache is stale")
return _McpReconciliation(_RECONCILE_FULL)
rejection = _frozen_task_snapshot_rejects(candidate_config)
if rejection is not None:
# Installing this revision's epochs would strand the durable callers
# that still run against the frozen startup configuration, so keep
# the conservative fallback and never install the rejected epoch.
logger.info(
"MCP configuration revision is rejected by the frozen durable-task snapshot (%s); resetting instead of installing binding epochs",
type(rejection).__name__,
)
return _McpReconciliation(_RECONCILE_FULL)
if applied is None:
if _server_tool_cache:
# Retained entries do not record the global interceptor chain they
# were built under, and there is no applied baseline to compare it
# against, so they are unverifiable. Fall back to the conservative
# full reset rather than risk serving a stale interceptor chain.
logger.info("MCP configuration changed with no applicable baseline but retained server entries; resetting conservatively")
return _McpReconciliation(_RECONCILE_FULL)
# No trusted baseline: conservatively re-epoch every active deployment
# binding (same fingerprint included) and tombstone unlisted deployment
# state, without replacing the pool or touching personal state.
logger.info("MCP configuration changed without an applicable selective baseline; conservatively rebinding deployment state")
return _McpReconciliation(
_RECONCILE_SELECTIVE,
revision=revision,
path=current_path,
signature=current_signature,
retire_unlisted=True,
force_rebind=frozenset(revision.stdio_connections),
)
return _McpReconciliation(
_RECONCILE_SELECTIVE,
revision=revision,
path=current_path,
signature=current_signature,
force_rebind=force_rebind,
)
def _invalidate_published_tools_locked() -> None:
"""Drop the published tool cache and supersede in-flight discovery.
Keeps the session-pool singleton and every binding epoch that
``_apply_mcp_reconciliation_locked`` did not change, so unchanged stdio
sessions keep serving. Caller must hold ``_init_condition``.
"""
global _mcp_tools_cache, _cache_initialized, _mcp_config_snapshot, _cache_generation
_mcp_tools_cache = None
_cache_initialized = False
_mcp_config_snapshot = None
_cache_generation += 1
_init_condition.notify_all()
def _apply_mcp_reconciliation_locked(plan: _McpReconciliation):
"""Apply ``plan`` under ``_init_condition``; return a retired pool, if any.
The whole transition is synchronous, so classification, epoch installation
and cache-generation invalidation cannot interleave with another in-process
reconciliation. Blocking owner teardown is never performed here: a selective
transition only detaches and signals owners (the pool's detached-owner reaper
owns the ``__aexit__``), and a full transition closes the retired pool after
the caller leaves the lock.
"""
global _applied_mcp_revision, _config_path, _config_signature, _initialized_without_config
if plan.kind == _RECONCILE_SELECTIVE:
from deerflow.mcp.session_pool import get_session_pool
revision = plan.revision
assert revision is not None
pool = get_session_pool()
active, removed = _deployment_binding_delta(_applied_mcp_revision, revision)
previous_connections = _applied_mcp_revision.stdio_connections if _applied_mcp_revision is not None else {}
re_epoching = sorted(name for name, fingerprint in active.items() if previous_connections.get(name) != fingerprint)
logger.info(
"MCP selective reconciliation: %d active stdio server(s), re-epoching %s, retiring %s",
len(active),
re_epoching or "none",
sorted(removed) or "none",
)
pool.reconcile_bindings(
active,
removed,
domain="deployment",
retire_unlisted=plan.retire_unlisted,
force_rebind=plan.force_rebind,
)
# Evict only after the new epoch is installed: a lifecycle-token rotation
# keeps the fingerprint but replaces the binding object, so evaluating
# reuse against the pre-transition pool would wrongly retain the entry.
_evict_server_tool_entries_locked(revision, pool)
_applied_mcp_revision = revision
_config_path, _config_signature = plan.path, plan.signature
_initialized_without_config = plan.path is None
_invalidate_published_tools_locked()
return None
assert plan.kind == _RECONCILE_FULL
logger.info("MCP conservative reconciliation: retiring the whole session pool and tool cache")
return _reset_mcp_tools_cache_state_and_retire_pool_locked()
def _plan_committed_mcp_reconciliation_locked(
config,
revision: _AppliedMcpRevision,
*,
path: Path,
signature: _ConfigSignature | None,
) -> _McpReconciliation | None:
"""Classify the exact revision a Gateway writer has already committed.
Caller must hold ``_init_condition``. Unlike the read-side planner, this
entry point never re-reads the file: the caller supplies the parsed config
and the post-write path/signature. That distinction is what lets two
sequential writers (delete A, then re-add identical A) each install their
own binding transition instead of coalescing to the final file state.
"""
global _applied_mcp_revision, _config_path, _config_signature, _initialized_without_config
from deerflow.mcp.session_pool import get_session_pool
pool = get_session_pool()
has_local_state = _cache_initialized or _applied_mcp_revision is not None or _initializing_generation is not None or bool(_server_tool_cache) or bool(pool.retained_server_names(domain="deployment"))
if not has_local_state:
# No visible local state, but a durable-task caller can bind between this
# observation and the record below (``ensure_binding`` takes only the pool
# lock). Install this revision's deployment epochs under the pool lock
# first, so an insertion in that window is re-epoched or tombstoned rather
# than recorded as applied without ever being fenced.
_install_claimed_revision_locked(pool, revision, previous=None)
_applied_mcp_revision = revision
_config_path, _config_signature = path, signature
_initialized_without_config = path is None
return None
applied = _applied_mcp_revision
force_rebind = lifecycle_delta(applied.lifecycle_tokens, revision.lifecycle_tokens, revision.stdio_connections) if applied is not None else frozenset()
if applied is not None and revision.effective_snapshot == applied.effective_snapshot and not force_rebind:
# Equal snapshot implies equal active connections, so only the consumed
# token map (e.g. tombstones) can have moved; record it without touching
# the pool or the published cache.
_applied_mcp_revision = revision
_config_path, _config_signature = path, signature
_initialized_without_config = path is None
return None
if applied is not None and revision.interceptors != applied.interceptors:
return _McpReconciliation(_RECONCILE_FULL)
if applied is None:
if _server_tool_cache:
# Mirrors the reader's no-baseline rule: retained entries do not
# record the global interceptor chain they were built under, so
# without a baseline they are unverifiable and force a full reset.
return _McpReconciliation(_RECONCILE_FULL)
# Conservatively re-epoch active deployment bindings and tombstone
# unlisted ones, personal untouched.
return _McpReconciliation(
_RECONCILE_SELECTIVE,
revision=revision,
path=path,
signature=signature,
retire_unlisted=True,
force_rebind=frozenset(revision.stdio_connections),
)
return _McpReconciliation(
_RECONCILE_SELECTIVE,
revision=revision,
path=path,
signature=signature,
force_rebind=force_rebind,
)
def prepare_mcp_reconciliation(
config,
*,
config_path: Path,
config_signature: _ConfigSignature | None = None,
) -> McpReconciliationPending:
"""Install the local fence for a config transition already committed to disk.
The caller must keep the extensions-config write lock held until this
function returns. The returned pending state is deliberately small: the
actual blocking teardown happens in :func:`finish_mcp_reconciliation` after
the caller releases the config locks.
"""
revision = _derived_applied_revision(config)
signature = config_signature if config_signature is not None else _get_config_signature(config_path)
rejection = _frozen_task_snapshot_rejects(config)
with _init_condition:
if revision is None or rejection is not None:
if rejection is not None:
logger.info(
"Committed MCP configuration is rejected by the frozen durable-task snapshot (%s); resetting instead of installing binding epochs",
type(rejection).__name__,
)
plan = _McpReconciliation(_RECONCILE_FULL)
else:
plan = _plan_committed_mcp_reconciliation_locked(
config,
revision,
path=config_path,
signature=signature,
)
retired_pool = None if plan is None else _apply_mcp_reconciliation_locked(plan)
return McpReconciliationPending(retired_pool=retired_pool)
def finish_mcp_reconciliation(pending: McpReconciliationPending) -> None:
"""Complete teardown that must run after the caller releases config locks."""
if pending.retired_pool is not None:
pending.retired_pool.close_all_sync()
def fail_mcp_reconciliation(error: Exception) -> McpReconciliationPending:
"""Conservatively retire local MCP state after a committed handoff failure.
The file is already committed, so the caller must report an uncertain
outcome rather than success. This function only detaches local state; the
caller finishes the blocking teardown after releasing config locks.
"""
logger.warning(
"MCP committed transition could not be reconciled (%s); retiring local cache state conservatively",
type(error).__name__,
)
with _init_condition:
retired_pool = _reset_mcp_tools_cache_state_and_retire_pool_locked()
return McpReconciliationPending(retired_pool=retired_pool)
def _is_cache_stale() -> bool:
"""Return True when the observed MCP configuration must be re-applied.
This is the classification entry point used by the cache's own hot paths.
A ``None`` plan means "nothing to do" (including the fail-soft "config
deleted after a successful load" contract), and the "effective MCP
configuration unchanged" case adopts the new path/signature before
returning False, exactly as the pre-PR-C predicate did. Callers that need to
apply a non-``None`` plan use ``_plan_mcp_reconciliation_locked`` directly so
classification and application happen under one critical section.
"""
return _plan_mcp_reconciliation_locked() is not None
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, _applied_mcp_revision, _server_tool_cache
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)
loaded_tools = None
loaded_snapshot = None
loaded_lifecycle = None
loaded_fingerprints: dict[str, str] = {}
loaded_revision = None
reusable_entries: dict[str, _ServerToolCacheEntry] = {}
pending_entries: dict[str, _ServerToolCacheEntry] = {}
candidate_groups: dict[str, ServerDiscoveryResult] = {}
post_path = None
post_sig = None
post_snapshot = None
post_lifecycle = None
loaded_reset_signature = None
post_reset_signature = None
init_succeeded = False
try:
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.session_pool import StaleMCPBindingError, get_session_pool
from deerflow.mcp.tools import _flatten_server_tool_groups, get_mcp_tools_by_server
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_fingerprints = _stdio_fingerprints(loaded_config)
try:
loaded_lifecycle = effective_tokens(loaded_config, parse_mcp_lifecycle(loaded_config.model_extra or {}))
except InvalidMcpLifecycle:
# An unverifiable ledger can never be published. Returning an empty
# result would make the lazy caller retry forever and respawn stdio
# servers, so retire any local state and abort discovery instead.
with _init_condition:
retired_for_invalid = _reset_mcp_tools_cache_state_and_retire_pool_locked()
if retired_for_invalid is not None:
retired_for_invalid.close_all_sync()
logger.warning("Extensions config mcpLifecycle is invalid; aborting MCP tool discovery")
raise RuntimeError("Extensions config contains an invalid mcpLifecycle; fix or remove it before using MCP tools") from None
loaded_reset_signature = _current_cache_reset_marker_signature(_resolve_config_path())
# Claim the exact pool this generation owns under the same lock the
# reset path takes, so verify-generation + capture-pool is one atomic
# ownership handoff. A superseded initializer must never resolve the
# singleton after the reset: that would install its stale fingerprint
# into the replacement pool the successor initializer runs on.
retired_before_claim = None
# The retired pool is always torn down, even when the validation further
# down fails: reset_session_pool() only fences and unlinks the singleton,
# so skipping close_all_sync() would leave that pool's owners running
# without ever receiving a close signal. The teardown itself stays
# outside every lock.
try:
with _init_condition:
if _cache_generation != claim_generation:
logger.info("MCP cache was reset before tool discovery; discarding superseded initialization")
return []
claimed_pool = get_session_pool()
if loaded_reset_signature != _cache_reset_marker_signature and _shared_reset_has_local_state_locked(claimed_pool):
# A shared reset published after the last staleness check but
# before this claim must retire local MCP state: adopting its
# marker here would swallow the reset and keep serving pooled
# sessions created before it. Retire first, then re-own this
# claim under the new generation on the replacement pool. A
# process with no local MCP state — not even a durable-task
# caller's deployment binding — has nothing to retire, so it
# adopts the current generation instead: that is how a restart
# picks up an existing marker without a needless reset, and why
# personal-domain-only state keeps its domain boundary.
logger.info("Shared MCP cache reset generation changed before this claim; retiring local MCP state")
retired_before_claim = _reset_mcp_tools_cache_state_and_retire_pool_locked()
claim_generation = _cache_generation
_initializing_generation = claim_generation
claimed_pool = get_session_pool()
# Claim the revision by *installing* it, not merely recording it, and
# do so before discovery runs: the applied baseline then really means
# "these deployment epochs are in place". A later observation must be
# classifiable against this revision even when nothing was ever
# published (failed/cancelled discovery), so a residual stdio binding
# from this revision can still be retired. An unbuildable revision
# clears the baseline instead, keeping the next change conservative
# rather than diffing against state we cannot trust.
# Record the marker this claim observed before anything can fail
# below: if it differed, the reset above already retired the state
# that generation required, so it must not be re-applied on retry.
_cache_reset_marker_signature = loaded_reset_signature
previous_revision = _applied_mcp_revision
loaded_revision = _derived_applied_revision(loaded_config)
if loaded_revision is None:
_applied_mcp_revision = None
else:
rejection = _frozen_task_snapshot_rejects(loaded_config)
if rejection is not None:
# Fail exactly as get_mcp_tools() would, but before any epoch
# is installed, so the durable callers that still use the
# frozen startup configuration keep working.
logger.warning(
"MCP configuration revision is rejected by the frozen durable-task snapshot (%s); no binding epoch installed",
type(rejection).__name__,
)
raise rejection
_install_claimed_revision_locked(claimed_pool, loaded_revision, previous=previous_revision)
_applied_mcp_revision = loaded_revision
# Select the entries this exact revision may reuse. The revision
# and the pool are both fixed under this lock, and ``_binding_matches``
# proves the entry still owns the pool's current binding. An
# untrusted revision (``loaded_revision is None``) reuses nothing.
# Compare each entry's wrapper chain, not the applied baseline:
# a claim can observe an interceptor edit after reconciliation,
# and a failed claim already advanced that baseline on retry.
if loaded_revision is not None:
for name in loaded_config.get_enabled_mcp_servers():
entry = _server_tool_cache.get(name)
if (
entry is not None
and loaded_revision.server_snapshots.get(name) == entry.snapshot
and loaded_revision.interceptors == entry.interceptors
and _binding_matches(entry.result, name, loaded_fingerprints.get(name), claimed_pool)
):
reusable_entries[name] = entry
finally:
if retired_before_claim is not None:
retired_before_claim.close_all_sync()
enabled_names = list(loaded_config.get_enabled_mcp_servers())
missing_names = [name for name in enabled_names if name not in reusable_entries]
discovered_groups: dict[str, ServerDiscoveryResult] = {}
try:
if missing_names:
# Only the missing servers are re-discovered; ``get_mcp_tools_by_server``
# already isolates per-server failures (a failed server is absent
# from the mapping, a zero-tool success is present with tools=()).
discovered_groups = await get_mcp_tools_by_server(
extensions_config=loaded_config,
server_names=missing_names,
session_pool=claimed_pool,
)
except StaleMCPBindingError:
# The pool this claim owns was retired while discovery was running.
# That is the same cache race as a generation change, so keep the
# existing "discard stale initialization" semantics; a stale binding
# on a pool this claim still owns stays a real error.
with _init_condition:
superseded = _cache_generation != claim_generation
if superseded:
logger.info("MCP cache was reset during binding installation; discarding superseded initialization")
return []
raise
# Local candidate: reused results plus the successful discoveries. A
# failed server is simply absent, so it is neither flattened nor cached.
candidate_groups = {name: entry.result for name, entry in reusable_entries.items()}
candidate_groups.update(discovered_groups)
pending_entries = dict(reusable_entries)
if loaded_revision is not None:
for name, result in discovered_groups.items():
snapshot = loaded_revision.server_snapshots.get(name)
if snapshot is not None:
pending_entries[name] = _ServerToolCacheEntry(snapshot=snapshot, interceptors=loaded_revision.interceptors, result=result)
if enabled_names and not candidate_groups:
# No enabled server produced a successful discovery result: this is
# a total discovery failure, not a legitimately empty configuration
# (a build-invalid connection is never even attempted), so refuse to
# publish a false-success empty cache.
# Raising aborts this attempt (the ``finally`` below releases the
# claim) without spinning inside a single ``get_cached_mcp_tools``.
raise RuntimeError("MCP discovery produced no results for any enabled server")
loaded_tools = _flatten_server_tool_groups(enabled_names, candidate_groups)
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_loaded = _read_stable_mcp_revision(post_path, post_sig)
if post_loaded is not None:
_post_config, post_revision = post_loaded
post_snapshot = post_revision.effective_snapshot
post_lifecycle = post_revision.lifecycle_tokens
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
post_lifecycle = {}
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_lifecycle == post_lifecycle and loaded_reset_signature == post_reset_signature
if publish:
# Re-validate every published result -- reused or freshly
# discovered -- against the pool's *current* binding. Selection
# and publication share ``_binding_matches``, so a binding
# rotation racing the lock-free discovery makes the whole
# candidate stale: no sibling is published either.
stale_binding = next(
(name for name, result in candidate_groups.items() if not _binding_matches(result, name, loaded_fingerprints.get(name), claimed_pool)),
None,
)
if stale_binding is not None:
logger.warning("MCP binding for server '%s' rotated during discovery; discarding the whole candidate", stale_binding)
publish = False
if not publish:
logger.warning("MCP config or shared reset generation changed during initialization; discarding stale result")
# Reconcile against the revision observed now instead of forcing
# a whole-pool replacement: only servers whose connection
# identity changed (or that disappeared) lose their session.
plan = _plan_mcp_reconciliation_locked()
if plan is None:
_invalidate_published_tools_locked()
else:
retired_pool = _apply_mcp_reconciliation_locked(plan)
else:
_server_tool_cache = pending_entries
_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
waiting_generation = None
with _init_condition:
plan = _plan_mcp_reconciliation_locked()
if plan is not None:
if plan.kind == _RECONCILE_SELECTIVE:
logger.info("MCP cache is stale, selectively reconciling affected stdio servers...")
else:
logger.info("MCP cache is stale, resetting for re-initialization...")
retired_pool = _apply_mcp_reconciliation_locked(plan)
# Applying a plan always clears the published cache, so this return
# cannot skip a retirement produced above.
if _cache_initialized:
return _mcp_tools_cache or []
if _initializing_generation is not None:
waiting_generation = _initializing_generation
# Deliver the retirement before every wait/retry path. `reset_session_pool()`
# only fences and unlinks the pool, so waiting for another initializer here
# would otherwise strand the retired pool's owners without a close signal.
if retired_pool is not None:
retired_pool.close_all_sync()
if waiting_generation is not None:
# Re-acquire the condition before waiting so the predicate cannot miss
# a wakeup, and wait on the exact generation that was observed.
with _init_condition:
_init_condition.wait_for(lambda: _cache_initialized or _initializing_generation != waiting_generation)
continue
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 as exc:
# Never emit the raw exception message or traceback here. Config
# parsing resolves ``$VAR`` values before validation and discovery
# failures can embed request URLs, so an exception's text may carry
# resolved credentials. The sanitized origins (config load, invalid
# ledger) already log their own bounded detail.
logger.error("Failed to lazy-initialize MCP tools (%s)", type(exc).__name__)
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, applied
or 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) — or already installed a binding epoch — still pays one
stat+sha256 of the config file per call to check for staleness;
* it invalidates an in-flight initialization (bumping the cache generation)
when the observed revision no longer matches what discovery was handed, so
tools discovered under a superseded config cannot publish;
* it applies a selective transition in place, or retires the pool outside
the lock for the conservative fallback, matching
``get_cached_mcp_tools``.
Returns:
True when existing cache state or an in-flight initialization was
retired or selectively reconciled.
"""
retired_pool = None
retired = False
with _init_condition:
plan = _plan_mcp_reconciliation_locked()
if plan is not None:
retired_pool = _apply_mcp_reconciliation_locked(plan)
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, _applied_mcp_revision
_mcp_tools_cache = None
_server_tool_cache.clear()
_cache_initialized = False
_config_path = None
_config_signature = None
_mcp_config_snapshot = None
_initialized_without_config = False
_cache_reset_marker_signature = None
# The replacement pool starts with no bindings, so there is no applied
# reconciliation baseline to diff against until the next discovery runs.
_applied_mcp_revision = 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
# The marker is replaced atomically under ``extensions_config_write_lock``
# and the cross-process ``extensions_config_file_lock``, the same discipline
# every ``extensions_config.json`` writer follows.
generation = MCP_CACHE_RESET_MARKER.publish(config_path)
# 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