mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-11 05:20:35 +00:00
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances
MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.
- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
per refresh_token connection keyed (scope, owner_user_id, server_key):
('deployment', '', name) or ('personal', owner, personal runtime
name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
ciphertext; a config fingerprint (token_url, client_id, grant, scope,
audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
peer's valid token, and otherwise claims the refresh with a lease plus
a version compare-and-set (database clock), fetches outside any
transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
LangGraph runtime for sqlite/postgres; personal managers pass their
owner. client_credentials stays in process; an unloadable key falls
back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
declared multi-instance deployment needs the shared key.
Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).
* docs(changelog): link the MCP OAuth token store entry to #7207
* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant
Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
save removes (deployment: _commit_mcp_config_write; personal:
_mutate, keeping each connection's enabled/disabled runtime-name
variants so a toggle keeps its chain). Best effort; no startup sweep,
since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
McpOAuthReauthorizationRequired: the tool error names the server and
asks for a new refresh token, plus the shared-key hint when this
instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.
* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters
Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
holder now renews its lease every third of REFRESH_LEASE_SECONDS while
the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
waiters outlast a live holder. Regression uses a real loopback endpoint
that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
(unsaved_base_version): they are written back before an older stored
token is adopted or refreshed with, retried every few seconds while the
access token is still valid, and dropped once a peer stored a newer
chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
a stored access token is reused only under the same request parameters;
otherwise the stored refresh-token chain is refreshed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
87 lines
3.5 KiB
Python
87 lines
3.5 KiB
Python
"""Persistent personal MCP connections, separate from deployment configuration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, read_raw_extensions_config
|
|
from deerflow.config.paths import get_paths
|
|
|
|
PERSONAL_SERVER_PREFIX = "personal_"
|
|
_FileSignature = tuple[int, int, int, int, int] | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PersonalMcpConfigSnapshot:
|
|
path: Path
|
|
signature: _FileSignature
|
|
config: ExtensionsConfig
|
|
|
|
|
|
def user_mcp_config_path(user_id: str) -> Path:
|
|
"""Use the same validated user root as skills and account integrations."""
|
|
return get_paths().user_dir(user_id) / "integrations" / "mcp.json"
|
|
|
|
|
|
def read_user_mcp_config(user_id: str) -> dict[str, Any]:
|
|
path = user_mcp_config_path(user_id)
|
|
if not path.exists():
|
|
return {"mcpServers": {}}
|
|
return read_raw_extensions_config(path)
|
|
|
|
|
|
def personal_server_name(user_id: str, name: str, configuration: dict[str, Any]) -> str:
|
|
"""Bind runtime sessions and durable tasks to one owner and config revision.
|
|
|
|
Display names remain local to each user's file. Never let a same-named
|
|
personal connection replace a deployment connection or another user's tools.
|
|
"""
|
|
identity = json.dumps([user_id, name, configuration], sort_keys=True)
|
|
return PERSONAL_SERVER_PREFIX + hashlib.sha256(identity.encode()).hexdigest()[:32]
|
|
|
|
|
|
def personal_server_name_variants(user_id: str, name: str, configuration: dict[str, Any]) -> set[str]:
|
|
"""Runtime names one saved connection takes as stored and with ``enabled`` toggled either way.
|
|
|
|
Toggling a connection off and on again restores its earlier runtime name,
|
|
so state keyed by that name (stored OAuth tokens) must survive the toggle.
|
|
"""
|
|
return {personal_server_name(user_id, name, variant) for variant in (configuration, {**configuration, "enabled": True}, {**configuration, "enabled": False})}
|
|
|
|
|
|
def load_user_mcp_config(user_id: str) -> ExtensionsConfig:
|
|
"""Read literal user values; personal files cannot resolve host $ENV secrets."""
|
|
raw = read_user_mcp_config(user_id)
|
|
servers = {}
|
|
for name, definition in raw.get("mcpServers", {}).items():
|
|
runtime_name = personal_server_name(user_id, name, definition)
|
|
servers[runtime_name] = {**definition, "tool_name_prefix": True}
|
|
return ExtensionsConfig.model_validate({"mcpServers": servers})
|
|
|
|
|
|
def _file_signature(path: Path) -> _FileSignature:
|
|
try:
|
|
stat = path.stat()
|
|
except FileNotFoundError:
|
|
return None
|
|
# Include file identity and ctime: atomic replacement can preserve size and
|
|
# mtime, while an in-place edit can preserve the inode.
|
|
return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
|
|
|
|
|
|
def load_user_mcp_config_if_changed(user_id: str, cached: PersonalMcpConfigSnapshot | None = None) -> PersonalMcpConfigSnapshot:
|
|
"""Reuse validated config while the owner's on-disk file is unchanged."""
|
|
path = user_mcp_config_path(user_id)
|
|
for _ in range(3):
|
|
signature = _file_signature(path)
|
|
if cached is not None and cached.path == path and cached.signature == signature:
|
|
return cached
|
|
config = load_user_mcp_config(user_id)
|
|
if _file_signature(path) == signature:
|
|
return PersonalMcpConfigSnapshot(path, signature, config)
|
|
raise RuntimeError("Personal MCP configuration changed during loading; retry the call")
|