mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +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>
525 lines
24 KiB
Python
525 lines
24 KiB
Python
"""Rotating OAuth credentials across discovery and durable deployment tasks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from urllib.parse import parse_qs
|
|
|
|
import httpx
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from cryptography.fernet import Fernet
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from deerflow.config.credentials_key import CredentialsCipher
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.mcp.cache import reset_mcp_tools_cache
|
|
from deerflow.mcp.oauth import get_initial_oauth_headers
|
|
from deerflow.mcp.oauth_store import McpOAuthTokenStore, set_mcp_oauth_token_store
|
|
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
|
|
from deerflow.mcp.tasks import OrdinaryMcpTaskDriver, TaskReference, TaskStatus
|
|
from deerflow.mcp.tasks import runtime as task_runtime
|
|
from deerflow.mcp.tasks.runtime import (
|
|
McpTaskConfigurationError,
|
|
set_mcp_task_config_snapshot,
|
|
set_mcp_task_submitter,
|
|
validate_mcp_task_config_snapshot,
|
|
)
|
|
from deerflow.mcp.tools import get_mcp_tools
|
|
from deerflow.mcp.user_config import load_user_mcp_config, personal_server_name, user_mcp_config_path
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.mcp_oauth_tokens import McpOAuthTokenRow, SqlMcpOAuthTokenRepository
|
|
|
|
|
|
class _Clock:
|
|
current = datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
@classmethod
|
|
def now(cls, _tz):
|
|
return cls.current
|
|
|
|
|
|
class _RotatingServer:
|
|
"""Replace only HTTP transport; run real MCP client/adapters and OAuth code."""
|
|
|
|
def __init__(self):
|
|
self.refresh_token = "refresh-0"
|
|
self.refresh_requests: list[str] = []
|
|
self.generation = 0
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
def handle(self, request: httpx.Request) -> httpx.Response:
|
|
if request.url.path == "/token":
|
|
submitted = parse_qs(request.content.decode())["refresh_token"][0]
|
|
self.refresh_requests.append(submitted)
|
|
if submitted != self.refresh_token:
|
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
|
self.generation += 1
|
|
self.refresh_token = f"refresh-{self.generation}"
|
|
return httpx.Response(
|
|
200,
|
|
json={"access_token": f"access-{self.generation}", "refresh_token": self.refresh_token, "expires_in": 120},
|
|
)
|
|
|
|
assert request.url.path == "/mcp"
|
|
authorization = request.headers.get("Authorization")
|
|
if authorization != f"Bearer access-{self.generation}":
|
|
return httpx.Response(401)
|
|
if request.method != "POST":
|
|
return httpx.Response(405)
|
|
message = json.loads(request.content)
|
|
method = message["method"]
|
|
if "id" not in message:
|
|
return httpx.Response(202)
|
|
if method == "initialize":
|
|
result = {
|
|
"protocolVersion": message["params"]["protocolVersion"],
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "reports", "version": "1.0"},
|
|
}
|
|
elif method == "tools/list":
|
|
result = {"tools": [{"name": name, "description": name, "inputSchema": {"type": "object", "properties": {}}} for name in ("submit_report", "status_report", "cancel_report", "search")]}
|
|
elif method == "tools/call":
|
|
name = message["params"]["name"]
|
|
self.calls.append((name, authorization))
|
|
result = {
|
|
"isError": False,
|
|
"content": [{"type": "text", "text": "ok"}],
|
|
"structuredContent": {"task_id": "remote-1", "status": "cancelled" if name == "cancel_report" else "running"},
|
|
}
|
|
else:
|
|
raise AssertionError(method)
|
|
return httpx.Response(200, json={"jsonrpc": "2.0", "id": message["id"], "result": result})
|
|
|
|
|
|
@pytest.fixture
|
|
def rotating_server(monkeypatch):
|
|
server = _RotatingServer()
|
|
original_client = httpx.AsyncClient
|
|
|
|
class OfflineClient(original_client):
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs["transport"] = httpx.MockTransport(server.handle)
|
|
kwargs["trust_env"] = False
|
|
super().__init__(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", OfflineClient)
|
|
monkeypatch.setattr("deerflow.mcp.oauth.datetime", _Clock)
|
|
monkeypatch.setattr(_Clock, "current", datetime(2026, 1, 1, tzinfo=UTC))
|
|
yield server
|
|
|
|
|
|
@pytest.fixture
|
|
def task_config(tmp_path, monkeypatch):
|
|
path = tmp_path / "extensions_config.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcpServers": {
|
|
"reports": {
|
|
"type": "http",
|
|
"url": "https://reports.example.invalid/mcp",
|
|
"oauth": {
|
|
"token_url": "https://reports.example.invalid/token",
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": "refresh-0",
|
|
"refresh_skew_seconds": 0,
|
|
},
|
|
"task_toolsets": [{"name": "reports", "submit_tool": "submit_report", "status_tool": "status_report", "cancel_tool": "cancel_report"}],
|
|
}
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(path))
|
|
startup = ExtensionsConfig.from_file()
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
yield path, startup
|
|
finally:
|
|
set_mcp_task_submitter(None)
|
|
set_mcp_task_config_snapshot(None)
|
|
|
|
|
|
@pytest.fixture
|
|
def shared_token_store(tmp_path):
|
|
"""Install the Gateway's shared token store over a fresh SQLite database."""
|
|
engine = sa.create_engine(f"sqlite:///{(tmp_path / 'oauth-tokens.db').as_posix()}")
|
|
Base.metadata.create_all(engine, tables=[McpOAuthTokenRow.__table__])
|
|
session_factory = sessionmaker(engine, expire_on_commit=False)
|
|
store = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=CredentialsCipher.from_keys([Fernet.generate_key()]))
|
|
previous = set_mcp_oauth_token_store(store)
|
|
try:
|
|
yield session_factory
|
|
finally:
|
|
set_mcp_oauth_token_store(previous)
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture(params=["process", "shared"])
|
|
def token_persistence(request):
|
|
"""Run a contract with tokens in process memory and with the shared store installed."""
|
|
if request.param == "shared":
|
|
request.getfixturevalue("shared_token_store")
|
|
return request.param
|
|
|
|
|
|
def _stored_keys(session_factory) -> list[tuple[str, str, str]]:
|
|
with session_factory() as session:
|
|
return [tuple(row) for row in session.execute(sa.select(McpOAuthTokenRow.scope, McpOAuthTokenRow.owner_user_id, McpOAuthTokenRow.server_key).order_by(McpOAuthTokenRow.scope))]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_submit_poll_and_rediscovery_share_rotating_oauth(task_config, rotating_server):
|
|
path, startup = task_config
|
|
initial_file = path.read_bytes()
|
|
initial_config = startup.model_dump()
|
|
driver = OrdinaryMcpTaskDriver(McpTaskToolCaller(startup))
|
|
captured_requests = []
|
|
|
|
# The Agent-visible wrapper reaches the real driver/caller. Persistence is
|
|
# outside this test's scope; the submitter only records the request/handle.
|
|
async def submit(*, driver_name, request):
|
|
captured_requests.append(request)
|
|
submission = await driver.submit(request)
|
|
assert submission.remote_task_id == "remote-1"
|
|
return {"id": "local-1", "status": submission.snapshot.status.value}
|
|
|
|
set_mcp_task_submitter(SimpleNamespace(submit=submit))
|
|
tools = await get_mcp_tools()
|
|
assert [tool.name for tool in tools] == ["reports_submit_report", "reports_search"]
|
|
graph = StateGraph(MessagesState, context_schema=dict)
|
|
graph.add_node("tools", ToolNode(tools, handle_tool_errors=False))
|
|
graph.add_edge(START, "tools")
|
|
graph.add_edge("tools", END)
|
|
result = await graph.compile().ainvoke(
|
|
{"messages": [AIMessage(content="", tool_calls=[{"name": "reports_submit_report", "args": {}, "id": "call-1", "type": "tool_call"}])]},
|
|
context={"user_id": "user-1", "thread_id": "thread-1", "thread_incarnation": "incarnation-1"},
|
|
)
|
|
assert json.loads(result["messages"][-1].content)["task_id"] == "local-1"
|
|
assert len(captured_requests) == 1
|
|
reference = TaskReference(
|
|
local_task_id="local-1",
|
|
user_id="user-1",
|
|
thread_id="thread-1",
|
|
server_name="reports",
|
|
remote_task_id="remote-1",
|
|
driver_data=captured_requests[0].driver_data,
|
|
)
|
|
|
|
_Clock.current += timedelta(seconds=121)
|
|
assert (await driver.get_status(reference)).status == TaskStatus.WORKING
|
|
# Ordinary tools on the same task-enabled server must follow the poller's
|
|
# refresh too, including tools rebuilt from the unchanged on-disk seed.
|
|
assert await tools[1].ainvoke({})
|
|
reset_mcp_tools_cache()
|
|
rediscovered = await get_mcp_tools()
|
|
assert [tool.name for tool in rediscovered] == ["reports_submit_report", "reports_search"]
|
|
assert await rediscovered[1].ainvoke({})
|
|
assert (await driver.cancel(reference)).status == TaskStatus.CANCELLED
|
|
|
|
assert rotating_server.refresh_requests == ["refresh-0", "refresh-1"]
|
|
assert rotating_server.calls == [
|
|
("submit_report", "Bearer access-1"),
|
|
("status_report", "Bearer access-2"),
|
|
("search", "Bearer access-2"),
|
|
("search", "Bearer access-2"),
|
|
("cancel_report", "Bearer access-2"),
|
|
]
|
|
assert startup.model_dump() == initial_config
|
|
assert path.read_bytes() == initial_file
|
|
validate_mcp_task_config_snapshot(startup)
|
|
validate_mcp_task_config_snapshot(ExtensionsConfig.from_file())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotation_does_not_mutate_loaded_config(task_config, rotating_server):
|
|
_path, startup = task_config
|
|
before = startup.model_dump()
|
|
caller = McpTaskToolCaller(startup)
|
|
await caller.call_tool(server_name="reports", tool_name="status_report", arguments={"task_id": "remote-1"}, user_id="user-1", thread_id="thread-1")
|
|
assert startup.model_dump() == before
|
|
validate_mcp_task_config_snapshot(startup)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("field", ["url", "refresh_token", "client_secret", "token_url"])
|
|
async def test_changed_task_credentials_still_require_restart(task_config, rotating_server, field):
|
|
_path, startup = task_config
|
|
await get_mcp_tools()
|
|
changed = ExtensionsConfig.from_file()
|
|
if field == "url":
|
|
changed.mcp_servers["reports"].url = "https://other.example.invalid/mcp"
|
|
else:
|
|
setattr(changed.mcp_servers["reports"].oauth, field, "changed")
|
|
with pytest.raises(McpTaskConfigurationError, match="restart DeerFlow"):
|
|
await get_mcp_tools(changed)
|
|
assert rotating_server.refresh_requests == ["refresh-0"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("transport", ["http", "sse"])
|
|
async def test_task_transports_share_discovery_token(task_config, rotating_server, monkeypatch, transport):
|
|
_path, startup = task_config
|
|
startup.mcp_servers["reports"].type = transport
|
|
set_mcp_task_config_snapshot(startup)
|
|
caller = McpTaskToolCaller(startup)
|
|
discovery = task_runtime.get_mcp_task_oauth_token_manager(startup.model_copy(deep=True))
|
|
assert await get_initial_oauth_headers(startup, token_manager=discovery) == {"reports": "Bearer access-1"}
|
|
connections = []
|
|
|
|
@asynccontextmanager
|
|
async def create_session(connection):
|
|
connections.append(connection)
|
|
yield SimpleNamespace(initialize=AsyncMock(), call_tool=AsyncMock(return_value="ok"))
|
|
|
|
monkeypatch.setattr("langchain_mcp_adapters.sessions.create_session", create_session)
|
|
assert await caller.call_tool(server_name="reports", tool_name="status_report", arguments={}, user_id="user-1", thread_id="thread-1") == "ok"
|
|
assert connections[0]["transport"] == transport
|
|
assert connections[0]["headers"] == {"Authorization": "Bearer access-1"}
|
|
assert rotating_server.refresh_requests == ["refresh-0"]
|
|
|
|
|
|
def test_separate_managers_deduplicate_refresh_across_event_loops(task_config, rotating_server, monkeypatch):
|
|
managers = [task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file()) for _ in range(3)]
|
|
entered = threading.Event()
|
|
all_acquiring = threading.Event()
|
|
release = threading.Event()
|
|
start = threading.Barrier(3)
|
|
handle = rotating_server.handle
|
|
state = managers[0]._states["reports"]
|
|
original_lock = state.lock
|
|
arrivals_lock = threading.Lock()
|
|
arrivals = 0
|
|
|
|
class ObservedLock:
|
|
def acquire(self):
|
|
nonlocal arrivals
|
|
with arrivals_lock:
|
|
arrivals += 1
|
|
if arrivals == len(managers):
|
|
all_acquiring.set()
|
|
return original_lock.acquire()
|
|
|
|
def release(self):
|
|
original_lock.release()
|
|
|
|
monkeypatch.setattr(state, "lock", ObservedLock())
|
|
|
|
async def held_response(request):
|
|
if request.url.path == "/token":
|
|
entered.set()
|
|
assert await asyncio.to_thread(release.wait, 5), "test did not release token response"
|
|
return handle(request)
|
|
|
|
monkeypatch.setattr(rotating_server, "handle", held_response)
|
|
|
|
def fetch(manager):
|
|
start.wait(timeout=5)
|
|
return asyncio.run(manager.get_authorization_header("reports"))
|
|
|
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
futures = [executor.submit(fetch, manager) for manager in managers]
|
|
try:
|
|
assert entered.wait(5), "token request did not start"
|
|
assert all_acquiring.wait(5), "callers did not contend on the shared refresh lock"
|
|
finally:
|
|
release.set()
|
|
assert [future.result(timeout=5) for future in futures] == ["Bearer access-1"] * 3
|
|
assert rotating_server.refresh_requests == ["refresh-0"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_waiter_does_not_strand_shared_refresh_lock(task_config, rotating_server, monkeypatch):
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file())
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file())
|
|
state = first._states["reports"]
|
|
original_lock = state.lock
|
|
entered = threading.Event()
|
|
|
|
class ObservedLock:
|
|
def acquire(self):
|
|
entered.set()
|
|
return original_lock.acquire()
|
|
|
|
def release(self):
|
|
original_lock.release()
|
|
|
|
monkeypatch.setattr(state, "lock", ObservedLock())
|
|
original_lock.acquire()
|
|
waiter = asyncio.create_task(first.get_authorization_header("reports"))
|
|
try:
|
|
assert await asyncio.to_thread(entered.wait, 5), "waiter never reached lock acquisition"
|
|
waiter.cancel()
|
|
finally:
|
|
original_lock.release()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(waiter, 5)
|
|
following = asyncio.create_task(second.get_authorization_header("reports"))
|
|
try:
|
|
# asyncio.wait has a bounded return even if cancellation cleanup must
|
|
# wait for an executor thread stuck acquiring a leaked lock.
|
|
done, _pending = await asyncio.wait({following}, timeout=5)
|
|
assert following in done, "cancelled waiter leaked the shared refresh lock"
|
|
assert following.result() == "Bearer access-1"
|
|
finally:
|
|
if original_lock.locked():
|
|
original_lock.release()
|
|
await asyncio.gather(following, return_exceptions=True)
|
|
assert rotating_server.refresh_requests == ["refresh-0"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_runtime_reset_drops_rotated_oauth_state(task_config, rotating_server, token_persistence):
|
|
_path, startup = task_config
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file())
|
|
assert await first.get_authorization_header("reports") == "Bearer access-1"
|
|
set_mcp_task_config_snapshot(None)
|
|
# A new lifetime needs a valid operator-supplied seed; retained in-process
|
|
# credentials must not silently survive teardown or override that seed.
|
|
startup.mcp_servers["reports"].oauth.refresh_token = "replacement-seed"
|
|
rotating_server.refresh_token = "replacement-seed"
|
|
set_mcp_task_config_snapshot(startup)
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(startup.model_copy(deep=True))
|
|
assert await second.get_authorization_header("reports") == "Bearer access-2"
|
|
assert rotating_server.refresh_requests == ["refresh-0", "replacement-seed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_runtime_restart_with_unchanged_seed_continues_the_stored_chain(task_config, rotating_server, shared_token_store):
|
|
_path, startup = task_config
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file())
|
|
assert await first.get_authorization_header("reports") == "Bearer access-1"
|
|
set_mcp_task_config_snapshot(None)
|
|
_Clock.current = _Clock.current + timedelta(seconds=300)
|
|
# The counterpart of the reset contract above: with the seed unchanged, a
|
|
# new Gateway lifetime continues from the stored rotation, not refresh-0.
|
|
set_mcp_task_config_snapshot(startup)
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(ExtensionsConfig.from_file())
|
|
assert await second.get_authorization_header("reports") == "Bearer access-2"
|
|
assert rotating_server.refresh_requests == ["refresh-0", "refresh-1"]
|
|
assert _stored_keys(shared_token_store) == [("deployment", "", "reports")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hot_reloaded_server_with_unchanged_seed_continues_the_stored_chain(task_config, rotating_server, shared_token_store):
|
|
_path, startup = task_config
|
|
other = startup.mcp_servers["reports"].model_copy(deep=True)
|
|
other.task_toolsets = []
|
|
startup.mcp_servers["other"] = other
|
|
set_mcp_task_config_snapshot(startup)
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(startup)
|
|
assert await first.get_authorization_header("other") == "Bearer access-1"
|
|
_Clock.current = _Clock.current + timedelta(seconds=300)
|
|
# A tool-cache reset builds a fresh manager for non-task servers.
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(startup.model_copy(deep=True))
|
|
assert await second.get_authorization_header("other") == "Bearer access-2"
|
|
assert rotating_server.refresh_requests == ["refresh-0", "refresh-1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hot_reloadable_servers_do_not_reuse_task_oauth_state(task_config, rotating_server, token_persistence):
|
|
_path, startup = task_config
|
|
other = startup.mcp_servers["reports"].model_copy(deep=True)
|
|
other.task_toolsets = []
|
|
startup.mcp_servers["other"] = other
|
|
set_mcp_task_config_snapshot(startup)
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(startup)
|
|
assert await first.get_authorization_header("other") == "Bearer access-1"
|
|
changed = startup.model_copy(deep=True)
|
|
changed.mcp_servers["other"].oauth.refresh_token = "replacement-seed"
|
|
rotating_server.refresh_token = "replacement-seed"
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(changed)
|
|
assert await second.get_authorization_header("other") == "Bearer access-2"
|
|
assert rotating_server.refresh_requests == ["refresh-0", "replacement-seed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_and_oauth_sharing_follow_the_same_server_selection(task_config, rotating_server, monkeypatch):
|
|
_path, startup = task_config
|
|
startup.mcp_servers["other"] = startup.mcp_servers["reports"].model_copy(deep=True)
|
|
# Simulate a narrower task-server policy. A server excluded by that policy
|
|
# must stay outside both the frozen snapshot and the shared OAuth lifetime.
|
|
monkeypatch.setattr(task_runtime, "_task_enabled_servers", lambda config: {"reports": config.mcp_servers["reports"]}, raising=False)
|
|
set_mcp_task_config_snapshot(startup)
|
|
first = task_runtime.get_mcp_task_oauth_token_manager(startup)
|
|
assert await first.get_authorization_header("reports") == "Bearer access-1"
|
|
changed = startup.model_copy(deep=True)
|
|
changed.mcp_servers["other"].oauth.refresh_token = "replacement-seed"
|
|
validate_mcp_task_config_snapshot(changed)
|
|
rotating_server.refresh_token = "replacement-seed"
|
|
second = task_runtime.get_mcp_task_oauth_token_manager(changed)
|
|
|
|
assert await second.get_authorization_header("reports") == "Bearer access-1"
|
|
assert await second.get_authorization_header("other") == "Bearer access-2"
|
|
assert rotating_server.refresh_requests == ["refresh-0", "replacement-seed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["discovery", "durable_call"])
|
|
async def test_personal_connection_cannot_reuse_same_named_deployment_oauth(task_config, rotating_server, tmp_path, monkeypatch, operation, token_persistence):
|
|
_path, startup = task_config
|
|
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
|
|
personal = startup.mcp_servers["reports"].model_dump(mode="json")
|
|
personal["oauth"]["refresh_token"] = "personal-seed"
|
|
personal["personal_public_network"] = True
|
|
path = user_mcp_config_path("user-1")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps({"mcpServers": {"reports": personal}}), encoding="utf-8")
|
|
name = personal_server_name("user-1", "reports", personal)
|
|
startup.mcp_servers = {name: startup.mcp_servers["reports"]}
|
|
set_mcp_task_config_snapshot(startup)
|
|
caller = McpTaskToolCaller(startup)
|
|
assert await get_initial_oauth_headers(startup, token_manager=task_runtime.get_mcp_task_oauth_token_manager(startup)) == {name: "Bearer access-1"}
|
|
rotating_server.refresh_token = "personal-seed"
|
|
before = path.read_bytes()
|
|
|
|
if operation == "discovery":
|
|
set_mcp_task_submitter(SimpleNamespace())
|
|
tools = await get_mcp_tools(load_user_mcp_config("user-1"), personal_user_id="user-1")
|
|
assert [tool.name for tool in tools] == [f"{name}_submit_report", f"{name}_search"]
|
|
assert await tools[1].ainvoke({})
|
|
else:
|
|
await caller.call_tool(server_name=name, tool_name="status_report", arguments={"task_id": "remote-1"}, user_id="user-1", thread_id="thread-1", connection_scope="personal")
|
|
assert rotating_server.refresh_requests == ["refresh-0", "personal-seed"]
|
|
assert rotating_server.calls[-1][1] == "Bearer access-2"
|
|
assert path.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_personal_connection_continues_its_rotated_chain_on_the_next_run(task_config, rotating_server, tmp_path, monkeypatch, shared_token_store):
|
|
_path, startup = task_config
|
|
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
|
|
personal = startup.mcp_servers["reports"].model_dump(mode="json")
|
|
personal["oauth"]["refresh_token"] = "personal-seed"
|
|
personal["personal_public_network"] = True
|
|
path = user_mcp_config_path("user-1")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps({"mcpServers": {"reports": personal}}), encoding="utf-8")
|
|
name = personal_server_name("user-1", "reports", personal)
|
|
rotating_server.refresh_token = "personal-seed"
|
|
set_mcp_task_submitter(SimpleNamespace())
|
|
|
|
# Discovery for one run builds its own manager ...
|
|
tools = await get_mcp_tools(load_user_mcp_config("user-1"), personal_user_id="user-1")
|
|
assert await tools[1].ainvoke({})
|
|
_Clock.current = _Clock.current + timedelta(seconds=300)
|
|
# ... and a later durable poll another one; both use the owner's row.
|
|
await McpTaskToolCaller(startup).call_tool(server_name=name, tool_name="status_report", arguments={"task_id": "remote-1"}, user_id="user-1", thread_id="thread-1", connection_scope="personal")
|
|
|
|
assert rotating_server.refresh_requests == ["personal-seed", "refresh-1"]
|
|
assert rotating_server.calls[-1][1] == "Bearer access-2"
|
|
assert _stored_keys(shared_token_store) == [("personal", "user-1", name)]
|