fix(mcp): selectively reconcile changed deployment servers (#6487)

* fix(mcp): selectively reconcile changed deployment servers

* fix(mcp): validate the frozen task snapshot before installing epochs

Durable background calls resolve their connection from the configuration frozen
at Gateway startup, so installing a newer revision's binding epoch fenced every
status/cancel call with `StaleMCPBindingError` — even though hot reload of that
server is correctly rejected. The rejection only landed in `get_mcp_tools()`,
which runs after the cache had already installed the epoch.

Both decisions that install an epoch now consult the frozen snapshot first. The
reconciliation planner falls back to the conservative full reset, where callers
that still run against the startup configuration rebind in the replacement pool,
and never installs the rejected epoch. A discovery claim raises the same
`McpTaskConfigurationError` that `get_mcp_tools()` would, before touching the
pool and without recording the revision as applied. Unrelated non-task servers
still reconcile selectively.

Adds the three requested regressions — a rejected hot change keeps background
task callers working, a rejected revision installs neither epoch nor tombstone
(changed connection and disabled server), and an unrelated server change stays
selective — plus mutation proofs for both new gates.

* fix(mcp): always tear down a pool the claim path retired

`reset_session_pool()` only fences and unlinks the singleton; the owners are
signalled by `close_all_sync()`. The claim ran that teardown *after* its critical
section, so a claim that retired the pool for a new shared-reset generation and
then raised — a revision the frozen durable-task snapshot rejects is the
reachable case — left the retired pool's sessions running without ever receiving
a close signal.

Wrap the claim in `try`/`finally` so the retired pool is torn down on every path,
still outside every lock and after the lock is released. Adds a regression
covering the shared-reset-then-rejected-revision sequence: the error propagates,
the retired pool's owner observes exactly one `__aexit__`, the replacement pool
holds no rejected epoch, and restoring the startup configuration initializes
normally again.

* fix(mcp): deliver retired-pool cleanup before the cache reader waits

A synchronous `get_cached_mcp_tools()` reader can apply a full reconciliation
while a discovery is in flight — an interceptor change and a new shared-reset
generation both qualify, because the applied baseline exists before any cache is
published. `reset_session_pool()` only fences and unlinks the pool, and the
reader then entered its wait/retry branch from inside the condition, skipping the
`close_all_sync()` that signals the retired pool's owners. The replacement cache
published normally while the discarded pool kept a live owner and stdio
subprocess that no later reset could reach.

Hoist the observed waiting generation out of the critical section and deliver the
retirement before every wait/retry path, then re-acquire the condition and wait
on that exact generation so the predicate cannot miss a wakeup. Cleanup stays
outside `_init_condition`, and the early `return _mcp_tools_cache` cannot skip a
retirement because applying a plan always clears the published cache.

Adds both triggers as a parametrised regression asserting the retired pool's
owner observes exactly one `__aexit__` and the registry drains — not merely that
the pool is fenced — plus a mutation proof for the wait-path cleanup.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
spud 2026-10-09 16:41:29 +08:00 committed by GitHub
parent 3e5bd081db
commit db1e0e10b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 2575 additions and 124 deletions

View File

@ -36,7 +36,9 @@
transport after its loop is gone. Normal `asyncio.run()` drains its owners.
- **Persistent stdio session capacity**: `MCPSessionPool.MAX_SESSIONS` is a hard cap on the live LRU registry. Capacity is enforced both before creating a session and when an in-flight session is promoted, because different keys can finish initialization concurrently after all observing spare capacity. Promotion-time victims are signalled and drained by separately tracked teardown tasks on their owning loops; the newly promoted owner must never await a victim, so a blocked victim exit cannot prevent its own closure or disconnect recovery.
- **Persistent stdio binding fence**: pooled session registry keys remain `(server_name, scope_key, owning_loop, ownership_domain)`. Separately, each `(ownership_domain, server_name)` has an opaque `ServerBinding` capability derived from a SHA-256 digest of the base stdio process identity (`transport`, `command`, `args`, configured `cwd`, configured `env`; the resolved values themselves are never stored in the binding repr). Discovery captures the exact pool plus binding before its first await, and durable stdio calls bind before per-call workspace `cwd`/`TMPDIR` augmentation. `get_session()` rejects bindings that are already superseded at admission, owner promotion, or the final post-await check. That final check is a check-before-return guard, not a lease across subsequent `call_tool()` use: a reconciliation racing after it detaches and closes the owner, and the caller then fails through the existing transport-disconnect handling. Binding reconciliation consumes already-classified active fingerprints/removals, atomically installs epochs/tombstones and detaches matching owners under the pool lock, and keeps those detached owners in the pool's teardown reaper so their `__aexit__` stays observable even when the caller drops the returned `PreparedRetirement`; configuration diffing remains a higher-layer responsibility.
- **Cache invalidation**: Staleness is detected in two stages. First, the resolved config path and a `(mtime, size, sha256)` content signature are compared against the values recorded when the cache was published, not a strict mtime `>` comparison — this catches same-second edits, mtime that stays put or moves backward (`git checkout`, `cp -p` / backup restore, `tar` / `rsync`, object-store / network mounts), and a switch to a different config file with an equal-or-older mtime. The signature helper (`config/file_signature.py::get_config_signature`) is shared with `config/app_config.py::get_app_config()` for the sibling runtime-editable config file, rather than each maintaining its own copy. Second, because the file also carries `skills` and middleware settings, a changed signature alone is not enough: `mcp/cache.py::_effective_mcp_config_snapshot()` serializes the effective MCP slice (enabled servers' parsed config in declaration order plus `mcpInterceptors`; the raw `transport` alias is dropped so equivalent `type`/`transport` spellings compare equal) and the cache is retired only when that slice changed. A skills-only or middleware-only edit therefore adopts the new signature and keeps the cached tools and pooled sessions, while any real MCP change keeps the existing process-wide reset through `_reset_mcp_tools_cache_state_and_retire_pool_locked()`. The snapshot may embed resolved credentials, so it stays in process memory and is never logged or persisted. `_is_cache_stale()` adopts a new signature when the MCP slice is unchanged, so it is not a read-only predicate; production callers run it under `_init_condition`. `ExtensionsConfig.resolve_config_path()` raises `FileNotFoundError` for an explicit `config_path`/`DEER_FLOW_EXTENSIONS_CONFIG_PATH` that points at a missing file — an operator-asserted path going missing is a real misconfiguration, so this is intentionally loud for callers that load the config for actual use (e.g. `from_file()` via `get_mcp_tools()`); only the fallback search mode returns `None`. The MCP cache's own path resolution (`mcp/cache.py::_resolve_config_path`) is narrower: it catches that specific `FileNotFoundError` locally and treats it the same as "unconfigured", so this staleness check degrades to "not stale" instead of propagating an exception when a previously-valid explicit/env-var config disappears mid-run. Two no-config states are distinguished: `_initialized_without_config` makes a cache published while no config existed stale as soon as one appears, while a config deleted after a successful load keeps the last-known-good fail-soft behavior and stays "not stale", while a file that is present but whose content cannot be hashed is treated as stale instead, because the cache cannot prove the revision it published is still the current one. `initialize_mcp_tools()` reads one `ExtensionsConfig` instance, hands that exact object to `get_mcp_tools(extensions_config=...)`, and publishes only when the effective MCP snapshot of the current config still equals the snapshot of the revision that produced the tools; otherwise it resets the tool cache through the same session-pool retirement path as normal stale invalidation before waiters retry. This closes the discovery window — tools built from a superseded MCP revision, or from a resolved path whose signature cannot be re-read, are never published — while a skills-only edit during discovery still publishes because the MCP slice is unchanged.
- **Process-local selective reconciliation**: `mcp/cache.py` keeps an *applied reconciliation baseline* (`_applied_mcp_revision`) apart from the published tool-cache baseline: the effective MCP snapshot, the normalized interceptor chain, and a `normalized_connection_fingerprint()` per enabled stdio server derived from `build_server_params()` (not a whole-config `model_dump()`, so `description`/`routing`/`tools`/`tool_name_prefix` are not connection changes); the snapshot is excluded from `repr` because it can embed resolved `$VAR` values. A claim *installs* the revision it records, so the baseline means those deployment epochs are in place: a stale binding left by a durable-task caller or an earlier failed attempt is repaired before `ensure_binding()` runs, which fails closed on a mismatch, and with no previous baseline the same lock tombstones the unlisted deployment servers the pool retains (`retire_unlisted=True`). Both epoch-installing decisions consult the frozen durable-task snapshot first: a rejected revision takes the conservative reset, and a claim fails as `get_mcp_tools()` would without touching the pool. It is recorded when discovery starts, so a failed/cancelled discovery that already seeded a binding can still retire it, and consecutive observations keep advancing epochs while the cache is empty. Classification and application both happen under `_init_condition` in one synchronous section, so epoch installation and generation invalidation cannot interleave or run out of order. An unchanged effective slice adopts the new path/signature and keeps the published tools; a changed interceptor chain, an unreadable/unverifiable revision, a shared-reset-marker change, a frozen-task rejection, or an unbuildable baseline keeps the conservative whole-pool reset; otherwise `reconcile_bindings(active, removed, domain="deployment")` runs without replacing the pool singleton, then the published tools are dropped and `_cache_generation` is bumped so an in-flight discovery cannot publish. Unchanged stdio servers keep their binding, session and owner; changed or removed ones advance an epoch or get a tombstone. Teardown stays with the pool's reaper outside every lock, even if the claim then fails. Claim and transition are deployment-scoped, so a deployment revision never retires personal state; the conservative fallbacks are whole-pool resets that do. A claim that observes a new shared-reset generation retires local state before adopting the marker whenever published tools, an applied baseline, or deployment-domain pooled state exist; an empty or personal-only process adopts it. Explicit `reset_mcp_tools_cache()` / `POST /api/mcp/cache/reset` and the Gateway writer path still reset fully; moving that handoff here is a follow-up.
- **Cache invalidation**: Staleness is detected in two stages. First, the resolved config path and a `(mtime, size, sha256)` content signature are compared against the values recorded when the cache was published, not a strict mtime `>` comparison — this catches same-second edits, mtime that stays put or moves backward (`git checkout`, `cp -p` / backup restore, `tar` / `rsync`, object-store / network mounts), and a switch to a different config file with an equal-or-older mtime. The signature helper (`config/file_signature.py::get_config_signature`) is shared with `config/app_config.py::get_app_config()` for the sibling runtime-editable config file, rather than each maintaining its own copy. Second, because the file also carries `skills` and middleware settings, a changed signature alone is not enough: `mcp/cache.py::_effective_mcp_config_snapshot()` serializes the effective MCP slice (enabled servers' parsed config in declaration order plus `mcpInterceptors`; the raw `transport` alias is dropped so equivalent `type`/`transport` spellings compare equal) and the cache is retired only when that slice changed. A skills-only or middleware-only edit therefore adopts the new signature and keeps the cached tools and pooled sessions, while a real MCP change is classified against the process-local applied-reconciliation baseline and applied selectively (see the selective-reconciliation bullet above) instead of unconditionally replacing the pool. The snapshot may embed resolved credentials, so it stays in process memory and is never logged or persisted. `_is_cache_stale()` adopts a new signature when the MCP slice is unchanged, so it is not a read-only predicate; production callers run it under `_init_condition`. `ExtensionsConfig.resolve_config_path()` raises `FileNotFoundError` for an explicit `config_path`/`DEER_FLOW_EXTENSIONS_CONFIG_PATH` that points at a missing file — an operator-asserted path going missing is a real misconfiguration, so this is intentionally loud for callers that load the config for actual use (e.g. `from_file()` via `get_mcp_tools()`); only the fallback search mode returns `None`. The MCP cache's own path resolution (`mcp/cache.py::_resolve_config_path`) is narrower: it catches that specific `FileNotFoundError` locally and treats it the same as "unconfigured", so this staleness check degrades to "not stale" instead of propagating an exception when a previously-valid explicit/env-var config disappears mid-run. Two no-config states are distinguished: `_initialized_without_config` makes a cache published while no config existed stale as soon as one appears, while a config deleted after a successful load keeps the last-known-good fail-soft behavior and stays "not stale", while a file that is present but whose content cannot be hashed is treated as stale instead, because the cache cannot prove the revision it published is still the current one. `initialize_mcp_tools()` reads one `ExtensionsConfig` instance, hands that exact object to `get_mcp_tools(extensions_config=...)`, and publishes only when the effective MCP snapshot of the current config still equals the snapshot of the revision that produced the tools; otherwise it resets the tool cache through the same session-pool retirement path as normal stale invalidation before waiters retry. This closes the discovery window — tools built from a superseded MCP revision, or from a resolved path whose signature cannot be re-read, are never published — while a skills-only edit during discovery still publishes because the MCP slice is unchanged.
- **Transports**: stdio (command-based), SSE, HTTP
- **Per-server tool-name prefixing**: `mcpServers.<server>.tool_name_prefix` defaults to `true`, preserving the collision-safe `<server_name>_` prefix. Servers whose tools already carry a stable namespace may set it to `false`; discovery then calls `langchain_mcp_adapters.tools.load_mcp_tools` with that server's flag. Source routing and stdio session-pool wrapping are based on the producing server and transport, never on whether the visible tool name starts with the server prefix.
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection. The rendered `<token_type> <access_token>` passes `mcp/headers.py::illegal_header_value_reason` inside `OAuthTokenManager.get_authorization_header` — the one boundary the tool interceptor, the initial discovery headers and the durable task path all read their value from — so a token endpoint returning something the transport would refuse fails closed instead of letting h11 echo the token into a model-visible tool error. The rendered value is what gets checked, not the two fields separately, because that is what the transport sees. The operator's static `headers` get the same check in `mcp/client.py::build_server_params`, where `build_servers_config` already drops just that server and logs the reason.
@ -56,7 +58,7 @@
- **Configured stdio working directory**: A non-empty `McpServerConfig.cwd` is forwarded by `build_server_params` for discovery, pooled Agent calls, and durable task calls. Omit the connection key when the setting is `None` or empty (including unresolved environment references), so the runtime's thread-workspace default still applies; HTTP/SSE connections must not receive it. `tests/test_mcp_cwd.py` exercises the JSON-config-to-subprocess path with relative entrypoints, relative tool inputs, and discovery/pool defaults for omitted, null, empty, and unresolved values.
- **Stdio disconnect recovery**: Ordinary Agent tool calls and durable task submit/status/cancel calls that receive the MCP SDK's explicit `Connection closed` error or an AnyIO closed-stream error evict only that `(ownership domain, server_name, incarnation scope)` session when the registered entry is still the same `ClientSession` that failed. A late error from an old concurrent call cannot evict its replacement or a new in-flight creation. The failing call still surfaces its original error and is never replayed automatically; a later retry creates a fresh subprocess/session. Protocol timeouts, normal `isError=true` tool results, and interceptor failures do not evict a healthy stateful session.
- **Stdio path translation**: MCP-returned local file references are not copied. If a `ResourceLink` or conservative free-text path resolves to an existing file inside the thread's mounted user-data tree, it is translated deterministically to `/mnt/user-data/...`; paths outside that tree remain unchanged. Bare-filename correlation must not replace a segment of a longer path: both `/` and `\` are separators at either boundary, even when the full-path pass leaves the reference unresolved. A trailing `\` immediately before a newline therefore counts as a separator too; that position is more often a Markdown hard break, but a backslash is also a legal filename character on POSIX, so forgoing that single rewrite is the conservative tradeoff. `tests/test_mcp_file_migration.py` covers this through the result converter with real workspace files.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (`PUT /api/mcp/config` keeps whole-payload validation, while `PATCH /api/mcp/config` changes only one server's `enabled` field, normalizes the same `type`/MCP-spec `transport` alias as the runtime config model, and validates the target only when enabling it). MCP, skill, and embedded-client writers hold the process-local `extensions_config_write_lock` plus the sidecar advisory `extensions_config_file_lock` for the complete read-modify-write/reload cycle, then share `atomic_write_extensions_config()`, which writes and fsyncs a same-directory temporary file before `os.replace()` and preserves an existing file's mode and symlink target; failed serialization or replacement leaves the prior config intact and cleans up the temporary file. Runtime updates are picked up either by the writer's explicit `reset_mcp_tools_cache()` or by another worker's two-stage staleness check above; a change that leaves the effective MCP slice unchanged (skills-only, middleware-only) no longer retires cached tools or pooled sessions, and when no MCP server is enabled the tool-assembly path calls `refresh_mcp_cache_if_active()`, which retires an already-initialized cache and pool (and voids an in-flight initialization) without performing new tool discovery or config hashing on a never-initialized process. The Gateway admin reset (`POST /api/mcp/cache/reset`) additionally calls `publish_mcp_tools_cache_reset()`: it writes a random generation to a hidden JSON marker beside the resolved config under the same cross-process lock (`MCP_CACHE_RESET_MARKER`, a `deerflow.config.shared_reset_marker.SharedResetMarker` shared with the skills prompt-cache reset), then retires local state. Every worker mounting that directory records the marker signature when tools publish and invalidates on a later signature, so a remote `tools/list` change converges without a fake config edit; marker drift during discovery fences the stale result. The response reports this transport as `scope=shared_config` rather than claiming an implicit broadcast to replicas with independent filesystems. No resolvable config path keeps the legacy process-local behavior and is reported as `scope=process`.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via the resolved-path + content-signature check above, so multi-worker / stale-mtime deployments still pick up an added/removed MCP server without a restart (`PUT /api/mcp/config` keeps whole-payload validation, while `PATCH /api/mcp/config` changes only one server's `enabled` field, normalizes the same `type`/MCP-spec `transport` alias as the runtime config model, and validates the target only when enabling it). MCP, skill, and embedded-client writers hold the process-local `extensions_config_write_lock` plus the sidecar advisory `extensions_config_file_lock` for the complete read-modify-write/reload cycle, then share `atomic_write_extensions_config()`, which writes and fsyncs a same-directory temporary file before `os.replace()` and preserves an existing file's mode and symlink target; failed serialization or replacement leaves the prior config intact and cleans up the temporary file. Runtime updates are picked up either by the writer's explicit `reset_mcp_tools_cache()` or by another worker's two-stage staleness check above; a change that leaves the effective MCP slice unchanged (skills-only, middleware-only) no longer retires cached tools or pooled sessions, and when no MCP server is enabled the tool-assembly path calls `refresh_mcp_cache_if_active()`, which applies the same classification (selective retirement of changed bindings, or the conservative full retirement), voids an in-flight initialization whose revision was superseded, and performs no tool discovery or config hashing on a never-initialized process. The Gateway admin reset (`POST /api/mcp/cache/reset`) additionally calls `publish_mcp_tools_cache_reset()`: it writes a random generation to a hidden JSON marker beside the resolved config under the same cross-process lock (`MCP_CACHE_RESET_MARKER`, a `deerflow.config.shared_reset_marker.SharedResetMarker` shared with the skills prompt-cache reset), then retires local state. Every worker mounting that directory records the marker signature when tools publish and invalidates on a later signature, so a remote `tools/list` change converges without a fake config edit; marker drift during discovery fences the stale result. The response reports this transport as `scope=shared_config` rather than claiming an implicit broadcast to replicas with independent filesystems. No resolvable config path keeps the legacy process-local behavior and is reported as `scope=process`.
- **Stdio launch policy at the HTTP boundary** (`routers/mcp.py::_validate_mcp_update_request`, shared by `PUT` and the enable branch of `PATCH`): a config file may express anything, but the API is untrusted input, so an API-registered stdio server must (a) name a bare executable from the allowlist — `_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST` = `{npx, uvx}`, extended by `DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST`, with path separators, whitespace, and shell metacharacters rejected in `command`; (b) carry no `args` flag in `_ARBITRARY_EXEC_ARGS`; and (c) set no `env` name in `_CODE_INJECTING_ENV_VARS`. Checks (b) and (c) exist because the command check alone names a binary without constraining what that binary runs. The `env` denylist applies to **every** allowlisted command, and both denylists match `--flag=value` as well as `--flag value`. The `args` denylist's **scope depends on the command**, because where a launcher stops parsing its own flags is what decides whether a token is an exec flag at all:
- For a **package launcher** in `_PACKAGE_LAUNCHERS` (`{npx, uvx}`) only the launcher's own **option region** is screened. `npx`/`uvx` stop parsing their flags at the package name and hand every later token to the spawned server's argv, where `-c` is routinely "config" and `-e` "env" — screening those rejected ordinary third-party servers while covering nothing. A bare `--` ends the region too: only the *first* token after it is the package name. Finding that boundary needs each launcher's option **arity**, since a value is not a positional — `npx -p <pkg> -c '<command>'` **runs** the command (`-p` is `npm exec`'s `--package`, so `<pkg>` is its value and npm keeps parsing), so ending the region at the first non-flag token would walk straight past it. `_NPX_BOOLEAN_ARGS` is generated from `@npmcli/config`'s definitions (npm 10.9.4) minus the `-p` exec override; `_UVX_VALUE_ARGS` comes from `uvx --help` (uv 0.11.1). Regenerate these against a newer launcher rather than hand-editing. The unknown-option default is deliberately **opposite** per launcher, following the exec set rather than symmetry: npx owns real exec flags (`-c`/`--call`), so an unknown option consumes a value and keeps the region open (npm errors on options it does not define, so this cannot reject a working invocation); uvx owns no string-eval flag at all, so its screen is a tripwire, an unknown option consumes nothing, and uv's large boolean surface cannot over-block. uvx's exec set also drops the short spellings, because `-c` is uv's `--constraints` and `-p` its `--python`.

View File

@ -4,6 +4,8 @@ import asyncio
import json
import logging
import threading
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from langchain_core.tools import BaseTool
@ -15,6 +17,57 @@ from deerflow.mcp.config_normalization import normalize_mcp_interceptor_paths, n
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.
``effective_snapshot`` is *not* secret-safe (it is the fully resolved MCP
slice), so it is excluded from ``repr`` — the same contract
``_mcp_config_snapshot`` documents. Never log or persist this object's
snapshot field.
"""
effective_snapshot: str = field(repr=False)
interceptors: str
stdio_connections: Mapping[str, str]
# 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
_mcp_tools_cache: list[BaseTool] | None = None
_cache_initialized = False
_init_lock = threading.RLock() # Guards cache state transitions.
@ -54,6 +107,25 @@ _initialized_without_config = False
# 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.
@ -138,6 +210,65 @@ def _effective_mcp_config_snapshot(config) -> str:
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 _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.
"""
from deerflow.mcp.client import build_server_params
from deerflow.mcp.session_pool import normalized_connection_fingerprint
stdio_connections: dict[str, str] = {}
for server_name, server in config.get_enabled_mcp_servers().items():
try:
connection = build_server_params(server_name, server)
except Exception:
# ``build_servers_config`` drops a server whose parameters cannot be
# built, so it is absent from discovery too. Omitting it here keeps
# the connection-identity baseline aligned with what discovery can
# actually install instead of raising into the hot staleness check.
logger.debug(
"MCP server '%s' has unusable parameters; omitting it from the connection-identity baseline",
server_name,
)
continue
if connection.get("transport", "stdio") == "stdio":
stdio_connections[server_name] = normalized_connection_fingerprint(connection)
return _AppliedMcpRevision(
effective_snapshot=_effective_mcp_config_snapshot(config),
interceptors=_interceptors_snapshot(config),
stdio_connections=stdio_connections,
)
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.
@ -148,13 +279,13 @@ def _signature_is_verifiable(signature: _ConfigSignature | None) -> bool:
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.
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 snapshot can be attributed to a single revision and the
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).
"""
@ -181,34 +312,141 @@ def _read_stable_mcp_snapshot(config_path: Path, expected_signature: _ConfigSign
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 _is_cache_stale() -> bool:
"""Check if the cache is stale due to config file changes.
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 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.
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
return config, _mcp_revision_from_config(config)
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.
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 _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)
pool.reconcile_bindings(
active,
removed,
domain="deployment",
retire_unlisted=previous is None,
)
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(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 and an in-flight discovery are
all reasons to keep watching the config. A process that never initialized
MCP pays no config-read cost (``refresh_mcp_cache_if_active`` contract).
"""
return _cache_initialized or _applied_mcp_revision is not None or _initializing_generation is not None
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 _config_path, _config_signature
if not _cache_initialized:
return False # Not initialized yet, not stale
if not _has_mcp_reconciliation_state():
return None
current_path, current_signature = _current_config_state()
@ -221,50 +459,142 @@ def _is_cache_stale() -> bool:
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
return _McpReconciliation(_RECONCILE_FULL)
# 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
# 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 current_signature is None:
return False
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 and _signature_is_verifiable(current_signature):
return False
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.
# 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
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
logger.info("MCP config content changed (signature %s -> %s), cache is stale", _config_signature, current_signature)
return True
applied = _applied_mcp_revision
if applied is not None and revision.effective_snapshot == applied.effective_snapshot:
logger.info("Extensions config changed but the effective MCP configuration did not; keeping cached MCP tools and sessions")
_config_path, _config_signature = current_path, current_signature
return None
if applied is not None and revision.interceptors == applied.interceptors:
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: replace the pool (where those callers
# rebind from the configuration they were started with) 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)
return _McpReconciliation(
_RECONCILE_SELECTIVE,
revision=revision,
path=current_path,
signature=current_signature,
)
# Either there is no applied baseline to diff against, or the global
# interceptor chain changed. Both keep the pre-PR-C conservative behavior:
# replace the pool instead of guessing which sessions may be reused.
logger.info(
"MCP configuration changed without an applicable selective baseline (interceptors %s); cache is stale",
"changed" if applied is not None else "unknown",
)
return _McpReconciliation(_RECONCILE_FULL)
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
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")
_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 _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:
@ -283,7 +613,7 @@ async def initialize_mcp_tools() -> list[BaseTool]:
"""
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
global _cache_reset_marker_signature, _applied_mcp_revision
while True:
with _init_condition:
@ -336,12 +666,67 @@ async def initialize_mcp_tools() -> list[BaseTool]:
# 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.
with _init_condition:
if _cache_generation != claim_generation:
logger.info("MCP cache was reset before tool discovery; discarding superseded initialization")
return []
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()
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
finally:
if retired_before_claim is not None:
retired_before_claim.close_all_sync()
try:
loaded_tools = await get_mcp_tools(
@ -401,7 +786,14 @@ async def initialize_mcp_tools() -> list[BaseTool]:
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()
# 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:
_mcp_tools_cache = loaded_tools
_cache_initialized = True
@ -436,21 +828,37 @@ def get_cached_mcp_tools() -> list[BaseTool]:
"""
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()
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:
_init_condition.wait_for(lambda: _initializing_generation is None or _cache_initialized)
continue
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
@ -488,26 +896,28 @@ def refresh_mcp_cache_if_active() -> bool:
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 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)
so tools discovered under the superseded config cannot publish;
* it retires the pool outside the lock, matching ``get_cached_mcp_tools``.
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.
retired or selectively reconciled.
"""
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()
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()
@ -518,7 +928,7 @@ 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
global _cache_reset_marker_signature, _applied_mcp_revision
_mcp_tools_cache = None
_cache_initialized = False
@ -527,6 +937,9 @@ def _reset_mcp_tools_cache_state() -> 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()

View File

@ -276,6 +276,7 @@ class MCPSessionPool:
removed: Collection[str],
*,
domain: MCPPoolDomain = "deployment",
retire_unlisted: bool = False,
) -> PreparedRetirement:
"""Apply already-classified binding changes and detach only changed owners.
@ -283,12 +284,25 @@ class MCPSessionPool:
supplies current fingerprints and removed names. Epoch installation,
registry detach and owner signalling are one non-awaiting critical
section, so a stale creator cannot resurrect after the transition.
``retire_unlisted`` makes that same critical section also tombstone every
server the pool still holds binding or session state for in ``domain``
that ``active`` does not declare. Callers set it when they have no applied
baseline of their own — a restarted process, where a durable-task caller's
binding is the only record that a server exists — because enumerating
those names separately would race a concurrent ``ensure_binding()``.
"""
entries: list[tuple[ClientSession, asyncio.AbstractEventLoop, asyncio.Task[Any], asyncio.Event]] = []
inflight: list[tuple[asyncio.AbstractEventLoop, asyncio.Future[ClientSession], asyncio.Task[Any], asyncio.Event]] = []
changed: set[str] = set()
with self._lock:
if retire_unlisted:
retained = {server_name for bind_domain, server_name in self._bindings if bind_domain == domain}
retained.update(key[0] for key in self._entries if key[3] == domain)
retained.update(key[0] for key in self._inflight if key[3] == domain)
removed = set(removed) | (retained - set(active))
for server_name, fingerprint in active.items():
binding_key = (domain, server_name)
self._binding_lifecycle_servers.add(binding_key)
@ -327,6 +341,21 @@ class MCPSessionPool:
return PreparedRetirement(entries=tuple(entries), inflight=tuple(inflight))
def retained_server_names(self, *, domain: MCPPoolDomain = "deployment") -> frozenset[str]:
"""Return the server names this pool retains binding or session state for.
The configuration layer needs this to retire servers that a revision no
longer declares when it has no applied baseline of its own: after a
restart, a durable-task caller's binding is the only record that the
server exists. The result is scoped to one ownership domain, so a
deployment revision can never retire a personal binding or session.
"""
with self._lock:
names = {server_name for bind_domain, server_name in self._bindings if bind_domain == domain}
names.update(key[0] for key in self._entries if key[3] == domain)
names.update(key[0] for key in self._inflight if key[3] == domain)
return frozenset(names)
def retire_all(self) -> None:
"""Fence this pool so stale wrappers cannot create sessions after reset."""
with self._lock:

View File

@ -53,6 +53,7 @@ _TRACKED_GLOBALS = (
"_mcp_config_snapshot",
"_initialized_without_config",
"_cache_reset_marker_signature",
"_applied_mcp_revision",
)
@ -87,6 +88,7 @@ def cache_globals():
"_mcp_config_snapshot",
"_initialized_without_config",
"_cache_reset_marker_signature",
"_applied_mcp_revision",
):
if hasattr(cache_module, name):
setattr(cache_module, name, None)
@ -326,7 +328,7 @@ class TestCrossLoopReinitialization:
try:
cache_module._mcp_tools_cache = None
cache_module._cache_initialized = False
for name in ("_config_path", "_config_signature", "_config_mtime"):
for name in ("_config_path", "_config_signature", "_config_mtime", "_applied_mcp_revision"):
if hasattr(cache_module, name):
setattr(cache_module, name, None)
cache_module._init_lock = threading.RLock()
@ -365,7 +367,7 @@ class TestCrossLoopReinitialization:
try:
cache_module._mcp_tools_cache = None
cache_module._cache_initialized = False
for name in ("_config_path", "_config_signature", "_config_mtime"):
for name in ("_config_path", "_config_signature", "_config_mtime", "_applied_mcp_revision"):
if hasattr(cache_module, name):
setattr(cache_module, name, None)
cache_module._init_lock = threading.RLock()
@ -414,7 +416,7 @@ class TestCrossLoopReinitialization:
try:
cache_module._mcp_tools_cache = None
cache_module._cache_initialized = False
for name in ("_config_path", "_config_signature", "_config_mtime"):
for name in ("_config_path", "_config_signature", "_config_mtime", "_applied_mcp_revision"):
if hasattr(cache_module, name):
setattr(cache_module, name, None)
cache_module._init_lock = threading.RLock()
@ -481,40 +483,45 @@ def test_config_change_during_initialization_discards_stale_tools(cache_globals,
assert calls == 2
def test_config_change_during_initialization_retires_pool_for_same_server_connection_change(cache_globals, monkeypatch, tmp_path):
"""Discarding a mid-load config change must also retire pooled sessions.
def test_config_change_during_initialization_reconciles_in_place_for_same_server(cache_globals, monkeypatch, tmp_path):
"""Discarding a mid-load config change must retire the superseded session.
A stale load can create a pooled session before ``initialize_mcp_tools``
notices that the config changed and discards the loaded tools. If the server
name and scope stay the same while the connection changes, the next load can
otherwise reuse that old session because ``MCPSessionPool`` keys only by
``(server_name, scope_key)``.
Auto-invalidation keeps the same pool here: only the changed server's binding
epoch advances, which fences the session created under the superseded
connection while leaving every other server untouched.
"""
from deerflow.mcp import session_pool as session_pool_module
from deerflow.mcp.client import build_server_params
from deerflow.mcp.session_pool import normalized_connection_fingerprint
class FakeSession:
def __init__(self, command: str) -> None:
self.command = command
class FakeSessionPool:
def __init__(self) -> None:
self.closed = False
self.sessions = {}
async def initialize(self) -> None:
return None
async def get_session(self, server_name, scope_key, connection):
key = (server_name, scope_key)
if key not in self.sessions:
self.sessions[key] = FakeSession(connection["command"])
return self.sessions[key]
class FakeSessionCm:
def __init__(self, connection: dict) -> None:
self.session = FakeSession(connection["command"])
def retire_all(self) -> None:
pass
async def __aenter__(self):
return self.session
def close_all_sync(self) -> None:
self.closed = True
async def __aexit__(self, *_exc) -> None:
return None
# The pool must stay real (its binding epochs are reconciled in place), so
# replace only the transport that would spawn ``npx``.
monkeypatch.setattr("langchain_mcp_adapters.sessions.create_session", FakeSessionCm)
real_reset_session_pool = session_pool_module.reset_session_pool
monkeypatch.setattr(session_pool_module, "MCPSessionPool", FakeSessionPool)
real_reset_session_pool()
old_pool = session_pool_module.get_session_pool()
@ -525,13 +532,17 @@ def test_config_change_during_initialization_retires_pool_for_same_server_connec
calls = 0
loaded_pools = []
loaded_sessions = []
installed_bindings = []
async def _fake_tools(**_kwargs):
async def _fake_tools(*, extensions_config, session_pool=None):
nonlocal calls
calls += 1
server = json.loads(cfg.read_text())["mcpServers"]["same"]
pool = session_pool_module.get_session_pool()
session = await pool.get_session("same", "thread-1", server)
pool = session_pool if session_pool is not None else session_pool_module.get_session_pool()
server = extensions_config.mcp_servers["same"]
connection = build_server_params("same", server)
binding = pool.ensure_binding("same", normalized_connection_fingerprint(connection), domain="deployment")
installed_bindings.append(binding)
session = await pool.get_session("same", "thread-1", connection, binding=binding)
loaded_pools.append(pool)
loaded_sessions.append(session)
if calls == 1:
@ -549,9 +560,13 @@ def test_config_change_during_initialization_retires_pool_for_same_server_connec
assert second == ["session-uvx"]
assert loaded_pools[0] is old_pool
assert loaded_pools[1] is not old_pool
assert loaded_pools[1] is old_pool
assert loaded_sessions[0] is not loaded_sessions[1]
assert old_pool.closed is True
assert loaded_sessions[0].command == "npx"
assert loaded_sessions[1].command == "uvx"
# The epoch that carried the superseded connection is no longer current.
assert old_pool._bindings[("deployment", "same")] is not installed_bindings[0]
assert old_pool._bindings[("deployment", "same")].fingerprint != installed_bindings[0].fingerprint
assert cache_module._cache_initialized is True
finally:
real_reset_session_pool()
@ -560,17 +575,18 @@ def test_config_change_during_initialization_retires_pool_for_same_server_connec
def test_superseded_initializer_cannot_seed_replacement_pool(cache_globals, monkeypatch, tmp_path):
"""A superseded initializer must not install its binding into the new pool.
Generation invalidation already discards the stale result, but
``ensure_binding()`` is a persistent side effect on the session pool. If the
stale initializer re-resolves the singleton *after* the reset, it seeds the
replacement pool with the fingerprint it read from the superseded config;
the successor initializer then fails closed on its own clean pool and the
cache can never publish again until an explicit reset.
A claim installs its revision's epochs into the pool it captured, so the
stale initializer holds a binding on the *retired* pool. The hazard is if it
re-resolves the singleton after the reset: it would seed the replacement pool
with the fingerprint it read from the superseded config, and the successor
initializer would then fail closed on its own pool and never publish again
until an explicit reset.
"""
from unittest.mock import AsyncMock
from deerflow.mcp import session_pool as session_pool_module
from deerflow.mcp import tools as tools_module
from deerflow.mcp.session_pool import normalized_connection_fingerprint
cfg = tmp_path / "extensions_config.json"
_write_extensions_config(cfg, {"A": _server("old-cmd")})
@ -627,8 +643,10 @@ def test_superseded_initializer_cannot_seed_replacement_pool(cache_globals, monk
new_pool = session_pool_module.get_session_pool()
assert new_pool is not old_pool
assert old_pool._bindings == {}
assert ("deployment", "A") in new_pool._bindings
# The replacement pool holds exactly the successor's own revision; the
# superseded claim never seeded it with the config it read.
assert new_pool._bindings[("deployment", "A")].fingerprint == normalized_connection_fingerprint({"transport": "stdio", "command": "new-cmd", "args": []})
assert set(new_pool._bindings) == {("deployment", "A")}
try:
asyncio.run(_scenario())
@ -707,8 +725,10 @@ def test_automatic_stale_invalidation_retires_session_pool_before_reinitializing
result = cache_module.get_cached_mcp_tools()
assert result == ["new-tools"]
assert loaded_pools == [session_pool_module.get_session_pool()]
assert loaded_pools[0] is not old_pool
# The pool singleton is preserved: automatic invalidation retires only
# the server whose connection identity changed, not every session.
assert loaded_pools == [old_pool]
assert session_pool_module.get_session_pool() is old_pool
assert cache_module._cache_initialized is True
finally:
real_reset_session_pool()
@ -1003,7 +1023,7 @@ def test_refresh_is_noop_before_initialization(cache_globals, monkeypatch):
def test_refresh_retires_cache_when_last_server_is_disabled(cache_globals, monkeypatch, tmp_path):
"""Disabling the last server still converges an already-initialized cache."""
"""Disabling the last server converges the cache without swapping the pool."""
from deerflow.mcp.session_pool import get_session_pool, reset_session_pool
cfg = tmp_path / "extensions_config.json"
@ -1020,8 +1040,11 @@ def test_refresh_retires_cache_when_last_server_is_disabled(cache_globals, monke
assert cache_module._cache_initialized is False
assert cache_module._mcp_tools_cache is None
assert cache_module._mcp_config_snapshot is None
assert get_session_pool() is not old_pool
assert closed == [True]
# The pool singleton and every other binding stay alive; the last
# deployment stdio server is the only one tombstoned.
assert get_session_pool() is old_pool
assert closed == []
assert old_pool._bindings[("deployment", "srv1")].fingerprint is None
finally:
reset_session_pool()

File diff suppressed because it is too large Load Diff