mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-29 06:30:36 +00:00
* fix(mcp): preserve server binding history across gateway workers A peer Gateway worker could keep a stale pooled session after another worker deleted and identically re-added a stdio server, because the final effective MCP slice matched the revision it had already applied. Record a per-server lifecycle generation in the shared extensions config and consume it in the reader, discovery claim, and committed handoff so an observed generation change force-rebinds only the affected server. Legacy configs derive a stable in-memory token; deletions leave permanent tombstones. * fix(mcp): fail closed on an invalid mcpLifecycle instead of retrying An unverifiable mcpLifecycle made initialize_mcp_tools() unpublishable, so get_cached_mcp_tools() retried discovery forever and could keep respawning stdio servers. Abort discovery on an invalid ledger after conservatively retiring local MCP state; a fixed config recovers on the next call. Also documents the rolling-upgrade prerequisite: cross-worker ABA detection requires configuration writes to go through a lifecycle-aware writer. * refactor(mcp): share stdio fingerprinting across lifecycle paths Reuse lifecycle._stdio_fingerprints from the applied-revision builder so the connection-identity rule has a single implementation, and move the unusable-server diagnostic into the shared helper. Add a regression proving the Gateway Skills writer preserves mcpLifecycle byte-for-value, and document that DeerFlowClient.update_mcp_config can raise RuntimeError after the config was committed. * docs(mcp): condense lifecycle guidance under the AGENTS chain limit The effective mcp/AGENTS.md chain was 15 bytes under the 98,304-byte CI hard limit, so the standalone cross-worker lifecycle bullet pushed it over. Fold the mcpLifecycle and rolling-upgrade facts into the existing selective-reconciliation bullet and condense it, keeping the chain under the limit while preserving the contract. * docs: trim inherited agent guidance below CI size limit --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
208 lines
8.5 KiB
Python
208 lines
8.5 KiB
Python
"""Shared per-server MCP lifecycle generations (``mcpLifecycle`` v1).
|
|
|
|
A Gateway worker installs binding epochs only for configuration transitions it
|
|
personally observes. A second worker sharing the same ``extensions_config.json``
|
|
can therefore miss a ``delete(A) -> identical re-add(A)`` when the final effective
|
|
MCP slice equals the revision it already applied. This module maintains the small
|
|
shared ledger that records a per-server *connection lifecycle* generation so a peer
|
|
can detect that history even when the fingerprint is unchanged.
|
|
|
|
The ledger stores only opaque tokens. It never contains connection configuration
|
|
or resolved credentials, and it is deliberately invisible to
|
|
``_effective_mcp_config_snapshot`` so the token difference is observable on its own.
|
|
|
|
Schema (top level of ``extensions_config.json``):
|
|
|
|
"mcpLifecycle": {"version": 1, "servers": {"A": "<32 lowercase hex>"}}
|
|
|
|
Tri-state parsing: the key is either absent (legacy, supported), valid, or
|
|
invalid. Invalid means unverifiable, and callers must fail closed (full reset for
|
|
readers, conservative migration for writers) rather than treat it as unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
import secrets
|
|
from collections.abc import Collection, Mapping
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LIFECYCLE_KEY = "mcpLifecycle"
|
|
LIFECYCLE_VERSION = 1
|
|
|
|
_TOKEN_RE = re.compile(r"[0-9a-f]{32}")
|
|
_LEGACY_PREFIX = "mcp-lifecycle-legacy:v1:"
|
|
|
|
|
|
class InvalidMcpLifecycle(ValueError):
|
|
"""Raised when ``mcpLifecycle`` is present but cannot be trusted."""
|
|
|
|
|
|
class McpLifecycleLedger(BaseModel):
|
|
"""Strict model for one ``mcpLifecycle`` block.
|
|
|
|
Pydantic's default coercion would accept ``version=True`` for an ``int`` field,
|
|
so the version and every token are validated with explicit ``type(...)`` checks.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
version: int
|
|
servers: dict[str, str]
|
|
|
|
@field_validator("version", mode="before")
|
|
@classmethod
|
|
def _strict_version(cls, value: Any) -> Any:
|
|
if type(value) is not int or value != LIFECYCLE_VERSION:
|
|
raise ValueError("unsupported mcpLifecycle version")
|
|
return value
|
|
|
|
@field_validator("servers", mode="before")
|
|
@classmethod
|
|
def _strict_servers(cls, value: Any) -> Any:
|
|
if type(value) is not dict:
|
|
raise ValueError("mcpLifecycle.servers must be an object")
|
|
for name, token in value.items():
|
|
if type(name) is not str or not name:
|
|
raise ValueError("mcpLifecycle server name must be a non-empty string")
|
|
if type(token) is not str or _TOKEN_RE.fullmatch(token) is None:
|
|
raise ValueError("mcpLifecycle token must be 32 lowercase hex characters")
|
|
return value
|
|
|
|
|
|
def new_token() -> str:
|
|
"""Return a fresh opaque lifecycle token (128 random bits, lowercase hex)."""
|
|
return secrets.token_hex(16)
|
|
|
|
|
|
def legacy_token(server_name: str, fingerprint: str) -> str:
|
|
"""Derive the stable in-memory token for a server with no ledger entry.
|
|
|
|
Depends only on the server name and the secret-safe connection fingerprint, so
|
|
every worker derives the same value before the first controlled write and no
|
|
unchanged legacy server is retired.
|
|
"""
|
|
material = f"{_LEGACY_PREFIX}{server_name}:{fingerprint}"
|
|
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def parse_mcp_lifecycle(raw_config: Mapping[str, Any] | None) -> McpLifecycleLedger | None:
|
|
"""Return the ledger, ``None`` when absent, or raise ``InvalidMcpLifecycle``.
|
|
|
|
Absent (legacy) is a supported state. Present-but-invalid is not.
|
|
"""
|
|
if raw_config is None or LIFECYCLE_KEY not in raw_config:
|
|
return None
|
|
try:
|
|
return McpLifecycleLedger.model_validate(raw_config[LIFECYCLE_KEY])
|
|
except Exception as exc: # noqa: BLE001 - any parse failure is unverifiable
|
|
raise InvalidMcpLifecycle("mcpLifecycle is present but invalid") from exc
|
|
|
|
|
|
def _stdio_fingerprints(config) -> dict[str, str]:
|
|
"""Return the enabled stdio server names mapped to connection fingerprints."""
|
|
from deerflow.mcp.client import build_server_params
|
|
from deerflow.mcp.session_pool import normalized_connection_fingerprint
|
|
|
|
fingerprints: dict[str, str] = {}
|
|
for server_name, server in config.get_enabled_mcp_servers().items():
|
|
try:
|
|
connection = build_server_params(server_name, server)
|
|
except Exception: # noqa: BLE001 - unusable servers are absent from discovery too
|
|
# ``build_servers_config`` drops a server whose parameters cannot be
|
|
# built, so it is absent from discovery too. Omitting it keeps the
|
|
# connection-identity baseline aligned with what discovery installs.
|
|
logger.debug(
|
|
"MCP server '%s' has unusable parameters; omitting it from the connection-identity baseline",
|
|
server_name,
|
|
)
|
|
continue
|
|
if connection.get("transport", "stdio") != "stdio":
|
|
continue
|
|
fingerprints[server_name] = normalized_connection_fingerprint(connection)
|
|
return fingerprints
|
|
|
|
|
|
def effective_tokens(config, ledger: McpLifecycleLedger | None) -> dict[str, str]:
|
|
"""Return the effective per-server token map for one parsed config.
|
|
|
|
Includes explicit ledger entries (tombstones included) plus legacy-derived
|
|
tokens for active stdio servers that have no ledger entry yet.
|
|
"""
|
|
tokens: dict[str, str] = dict(ledger.servers) if ledger is not None else {}
|
|
for server_name, fingerprint in _stdio_fingerprints(config).items():
|
|
tokens.setdefault(server_name, legacy_token(server_name, fingerprint))
|
|
return tokens
|
|
|
|
|
|
def lifecycle_delta(
|
|
previous_tokens: Mapping[str, str],
|
|
candidate_tokens: Mapping[str, str],
|
|
active_names: Collection[str],
|
|
) -> frozenset[str]:
|
|
"""Return active servers whose effective lifecycle token changed.
|
|
|
|
These are the servers that must be force-rebound even when their connection
|
|
fingerprint is unchanged. A name absent from ``previous_tokens`` counts as
|
|
changed so a newly tracked server is installed conservatively.
|
|
"""
|
|
return frozenset(name for name in active_names if candidate_tokens.get(name) != previous_tokens.get(name))
|
|
|
|
|
|
def plan_mcp_lifecycle(
|
|
previous_raw: Mapping[str, Any] | None,
|
|
candidate_raw: Mapping[str, Any],
|
|
*,
|
|
candidate_config=None,
|
|
) -> dict[str, Any]:
|
|
"""Return the full ``mcpLifecycle`` block to embed in ``candidate_raw``.
|
|
|
|
The slot of a server is its enabled stdio fingerprint, so any identity change,
|
|
delete, re-add or brand-new name is one lifecycle transition. ``previous_raw``
|
|
must be the raw config read *before* the mutation being committed, and must not
|
|
alias ``candidate_raw``.
|
|
|
|
A previous config whose server schema cannot be parsed is a conservative
|
|
migration: every candidate stdio server gets a fresh token, and tombstones are
|
|
kept only when the old ledger itself is valid. Unreadable JSON is the caller's
|
|
responsibility to abort before reaching this function.
|
|
"""
|
|
from deerflow.config.extensions_config import validate_raw_extensions_config
|
|
|
|
if candidate_config is None:
|
|
candidate_config = validate_raw_extensions_config(dict(candidate_raw))
|
|
new_fingerprints = _stdio_fingerprints(candidate_config)
|
|
|
|
previous_provable = previous_raw is not None
|
|
previous_fingerprints: dict[str, str] = {}
|
|
previous_ledger: McpLifecycleLedger | None = None
|
|
if previous_raw is not None:
|
|
try:
|
|
previous_fingerprints = _stdio_fingerprints(validate_raw_extensions_config(dict(previous_raw)))
|
|
except Exception: # noqa: BLE001 - schema-invalid previous config
|
|
previous_provable = False
|
|
try:
|
|
previous_ledger = parse_mcp_lifecycle(previous_raw)
|
|
except InvalidMcpLifecycle:
|
|
previous_provable = False
|
|
previous_ledger = None
|
|
|
|
ledger = dict(previous_ledger.servers) if previous_ledger is not None else {}
|
|
|
|
if not previous_provable:
|
|
for server_name in new_fingerprints:
|
|
ledger[server_name] = new_token()
|
|
return {"version": LIFECYCLE_VERSION, "servers": ledger}
|
|
|
|
for server_name in set(previous_fingerprints) | set(new_fingerprints) | set(ledger):
|
|
if previous_fingerprints.get(server_name) != new_fingerprints.get(server_name):
|
|
# identity change, delete or re-add: always a fresh generation
|
|
ledger[server_name] = new_token()
|
|
return {"version": LIFECYCLE_VERSION, "servers": ledger}
|