mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
* fix(mcp): keep a tool argument named runtime out of LangGraph's runtime injection The pooled stdio wrapper and the background-submit wrapper took LangGraph's ToolRuntime in a parameter called `runtime`. ToolNode injects by that name, so a tool whose own input schema has a `runtime` property had the model's value replaced by the ToolRuntime and then lost: the server received the other arguments only, and TaskSubmitRequest.arguments missed it too. Name the injected parameter `deerflow_runtime` in both wrappers (it is still detected through its ToolRuntime annotation) and read it under that name in the personal-MCP ownership guard. Existing tests that call the wrappers pass the new name; two ToolNode tests cover a `runtime` business argument on each path. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(mcp): read the injected runtime under the right name in the personal-tool guard The personal-tool ownership guard read kwargs["deerflow_runtime"], but only the pooled stdio wrapper was renamed; personal HTTP/SSE tools still carry langchain-mcp-adapters' `runtime` parameter, so the guard never saw the authoritative runtime user for them. Look the parameter name up per tool, and update the mcp AGENTS.md note on how the runtime is injected. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
853 lines
43 KiB
Python
853 lines
43 KiB
Python
"""Personal MCP persistence, HTTP ownership and real MCP credential routing."""
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from langchain_core.tools import ToolException
|
|
|
|
from app.gateway.routers import personal_mcp
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.mcp.user_config import load_user_mcp_config, personal_server_name, read_user_mcp_config, user_mcp_config_path
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
|
|
@pytest.fixture
|
|
def personal_client(tmp_path, monkeypatch):
|
|
from deerflow.config.paths import Paths
|
|
|
|
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr("deerflow.mcp.personal_access._admin_checker", AsyncMock(return_value=True))
|
|
monkeypatch.setattr("app.gateway.personal_mcp_access._is_current_admin", AsyncMock(return_value=True))
|
|
app = FastAPI()
|
|
|
|
@app.middleware("http")
|
|
async def identity(request, call_next):
|
|
if name := request.headers.get("test-user"):
|
|
request.state.user = SimpleNamespace(id=name, system_role=request.headers.get("test-role", "admin"))
|
|
request.state.auth_source = "session"
|
|
return await call_next(request)
|
|
|
|
app.include_router(personal_mcp.router)
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
def create(client, user, *, name="github", token=None, role="admin"):
|
|
return client.post(
|
|
"/api/mcp/personal/config/servers",
|
|
headers={"test-user": user, "test-role": role},
|
|
json={"mcp_servers": {name: {"type": "http", "url": "https://example.com/mcp", "headers": {"Authorization": token or f"Bearer {user}"}}}},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_personal_config_write_drains_started_mutation_across_cancellation(monkeypatch):
|
|
started = asyncio.Event()
|
|
|
|
monkeypatch.setattr(personal_mcp, "_owner", AsyncMock(return_value="alice"))
|
|
monkeypatch.setattr(personal_mcp, "is_admin_user", AsyncMock(return_value=True))
|
|
|
|
def mutate(*_args, **_kwargs):
|
|
started_loop.call_soon_threadsafe(started.set)
|
|
release_thread.wait(timeout=5)
|
|
return {"mcpServers": {}}
|
|
|
|
import threading
|
|
|
|
started_loop = asyncio.get_running_loop()
|
|
release_thread = threading.Event()
|
|
monkeypatch.setattr(personal_mcp, "_mutate", mutate)
|
|
|
|
task = asyncio.create_task(personal_mcp._write(SimpleNamespace(), "delete", "missing"))
|
|
try:
|
|
await asyncio.wait_for(started.wait(), timeout=5)
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
|
|
release_thread.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release_thread.set()
|
|
if not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
def test_persistent_same_name_connections_are_owner_only(personal_client):
|
|
client = personal_client
|
|
assert client.get("/api/mcp/personal/config").status_code == 401
|
|
for user in ("alice", "bob"):
|
|
result = create(client, user)
|
|
assert result.status_code == 200, result.text
|
|
assert result.json()["mcp_servers"]["github"]["headers"]["Authorization"] == "***"
|
|
assert read_user_mcp_config("alice")["mcpServers"]["github"]["headers"]["Authorization"] == "Bearer alice"
|
|
assert read_user_mcp_config("bob")["mcpServers"]["github"]["headers"]["Authorization"] == "Bearer bob"
|
|
if os.name != "nt": # chmod cannot express owner-only modes on Windows
|
|
assert user_mcp_config_path("alice").stat().st_mode & 0o777 == 0o600
|
|
assert create(client, "alice").status_code == 409
|
|
assert create(client, "alice", name="only-alice").status_code == 200
|
|
assert client.delete("/api/mcp/personal/config/servers/only-alice", headers={"test-user": "bob"}).status_code == 404
|
|
result = client.get("/api/mcp/personal/config", headers={"test-user": "bob"})
|
|
assert "only-alice" not in result.text
|
|
before = user_mcp_config_path("alice").read_bytes()
|
|
assert client.patch("/api/mcp/personal/config", headers={"test-user": "bob"}, json={"server_name": "github", "enabled": False}).status_code == 200
|
|
assert user_mcp_config_path("alice").read_bytes() == before
|
|
# A fresh read from disk, not a request-local cache, retains each owner.
|
|
assert len(load_user_mcp_config("alice").get_enabled_mcp_servers()) == 2
|
|
assert not load_user_mcp_config("bob").get_enabled_mcp_servers()
|
|
|
|
|
|
@pytest.mark.parametrize("contents", ["{", "[]"])
|
|
def test_corrupt_personal_config_returns_client_errors_without_overwriting_it(personal_client, contents):
|
|
path = user_mcp_config_path("alice")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(contents)
|
|
headers = {"test-user": "alice"}
|
|
requests = [
|
|
("GET", "/api/mcp/personal/config", None),
|
|
("POST", "/api/mcp/personal/config/servers", {"mcp_servers": {"new": {"type": "http", "url": "https://example.com/mcp"}}}),
|
|
("PUT", "/api/mcp/personal/config/server", {"server_name": "new", "server": {"type": "http", "url": "https://example.com/mcp"}}),
|
|
("PATCH", "/api/mcp/personal/config", {"server_name": "new", "enabled": False}),
|
|
("DELETE", "/api/mcp/personal/config/servers/new", None),
|
|
]
|
|
for method, url, body in requests:
|
|
response = personal_client.request(method, url, headers=headers, json=body)
|
|
assert response.status_code == 400, (method, response.text)
|
|
assert "Extensions configuration" in response.json()["detail"]
|
|
assert path.read_text() == contents
|
|
assert personal_client.get("/api/mcp/personal/config", headers={"test-user": "bob"}).status_code == 200
|
|
|
|
|
|
def test_masked_edit_and_delete_do_not_touch_platform_or_peer(personal_client, tmp_path, monkeypatch):
|
|
client = personal_client
|
|
platform = tmp_path / "platform.json"
|
|
platform.write_text(json.dumps({"mcpServers": {"github": {"type": "http", "url": "https://example.com/platform"}}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(platform))
|
|
before = platform.read_bytes()
|
|
create(client, "alice")
|
|
create(client, "bob")
|
|
result = client.put("/api/mcp/personal/config/server", headers={"test-user": "alice"}, json={"server_name": "github", "server": {"type": "http", "url": "https://example.com/updated", "headers": {"Authorization": "***"}}})
|
|
assert result.status_code == 200, result.text
|
|
assert read_user_mcp_config("alice")["mcpServers"]["github"]["headers"]["Authorization"] == "Bearer alice"
|
|
assert client.delete("/api/mcp/personal/config/servers/github", headers={"test-user": "alice"}).status_code == 200
|
|
assert not load_user_mcp_config("alice").mcp_servers
|
|
assert read_user_mcp_config("bob")["mcpServers"]["github"]["headers"]["Authorization"] == "Bearer bob"
|
|
assert platform.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize("contents", ["{", "[]"])
|
|
@pytest.mark.parametrize("adapter", ["mcp", "business"])
|
|
def test_corrupt_personal_config_in_capability_listings(personal_client, tmp_path, monkeypatch, contents, adapter):
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.routers import capabilities
|
|
|
|
app = personal_client.app
|
|
app.include_router(capabilities.router)
|
|
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
|
|
deployment = tmp_path / "deployment.json"
|
|
deployment.write_text(json.dumps({"mcpServers": {"shared": {"type": "http", "url": "https://example.com/mcp"}}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(deployment))
|
|
path = user_mcp_config_path("alice")
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(contents)
|
|
url = f"/api/capabilities/installations/{adapter}"
|
|
for scope in ("user", "all"):
|
|
response = personal_client.get(url, params={"scope": scope}, headers={"test-user": "alice"})
|
|
assert response.status_code == 400
|
|
assert "Extensions configuration" in response.json()["detail"]
|
|
assert personal_client.get(url, params={"scope": "deployment"}, headers={"test-user": "alice"}).status_code == 200
|
|
assert personal_client.get(url, params={"scope": "all"}, headers={"test-user": "bob"}).status_code == 200
|
|
assert path.read_text() == contents
|
|
|
|
|
|
def test_personal_values_do_not_resolve_platform_environment(personal_client, monkeypatch):
|
|
monkeypatch.setenv("PLATFORM_ONLY_KEY", "platform-secret")
|
|
assert create(personal_client, "alice", token="$PLATFORM_ONLY_KEY").status_code == 200
|
|
config = load_user_mcp_config("alice")
|
|
assert next(iter(config.mcp_servers.values())).headers["Authorization"] == "$PLATFORM_ONLY_KEY"
|
|
|
|
|
|
def test_catalog_listing_and_installation_respect_owner(personal_client, monkeypatch, tmp_path):
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.routers import capabilities
|
|
|
|
app = personal_client.app
|
|
app.include_router(capabilities.router)
|
|
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
|
|
platform = tmp_path / "deployment.json"
|
|
platform.write_text(json.dumps({"mcpServers": {"shared": {"type": "http", "url": "https://example.com/platform"}}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(platform))
|
|
monkeypatch.setattr("deerflow.community.url_safety.validate_public_http_url", lambda *a, **k: None)
|
|
response = personal_client.post(
|
|
"/api/capabilities/installations",
|
|
headers={"test-user": "alice", "test-role": "user"},
|
|
json={"plugin_id": "github", "name": "my-github", "scope": "user", "configuration": {"type": "http", "url": "https://example.com/mcp", "headers": {"Authorization": "Bearer alice"}}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["can_manage"] is True
|
|
assert response.json()["items"][0]["scope"] == "user"
|
|
for user, expected in (("alice", {"shared", "my-github"}), ("bob", {"shared"})):
|
|
response = personal_client.get("/api/capabilities/installations/mcp?scope=all", headers={"test-user": user})
|
|
assert response.status_code == 200, response.text
|
|
assert {item["name"] for item in response.json()["items"]} == expected
|
|
assert "Bearer alice" not in response.text
|
|
|
|
|
|
def test_tool_assembly_combines_platform_and_only_current_owner(personal_client, monkeypatch, tmp_path):
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
path = tmp_path / "deployment.json"
|
|
path.write_text(json.dumps({"mcpServers": {"shared": {"type": "http", "url": "https://example.com/mcp"}}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(path))
|
|
shared = tag_mcp_tool(StructuredTool.from_function(lambda: "platform", name="shared_test", description="Shared tool"), server_name="shared")
|
|
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [shared])
|
|
|
|
async def discover(config, **kwargs):
|
|
name = next(iter(config.mcp_servers))
|
|
|
|
async def echo():
|
|
return config.mcp_servers[name].headers["Authorization"]
|
|
|
|
return [tag_mcp_tool(StructuredTool.from_function(coroutine=echo, name=name + "_test", description="Personal tool"), server_name=name)]
|
|
|
|
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", discover)
|
|
config = SimpleNamespace(tools=[], models=[], get_model_config=lambda _: None)
|
|
for user in ("alice", "bob"):
|
|
create(personal_client, user)
|
|
identity = set_current_user(SimpleNamespace(id=user))
|
|
try:
|
|
tools = get_available_tools(app_config=config)
|
|
assert shared in tools
|
|
personal = [tool for tool in tools if tool.name.startswith("personal_")]
|
|
assert len(personal) == 1
|
|
assert personal[0].invoke({}) == f"Bearer {user}"
|
|
# Selecting the peer's installation cannot add that peer's tools.
|
|
peer = "bob" if user == "alice" else "alice"
|
|
peer_id = next(iter(read_user_mcp_config(peer)["mcpServers"].values()), {}).get("capability", {}).get("id", "not-owned")
|
|
assert not any(tool.name.startswith("personal_") for tool in get_available_tools(app_config=config, mcp_plugins=[peer_id]))
|
|
finally:
|
|
reset_current_user(identity)
|
|
|
|
|
|
def test_deployment_name_collision_does_not_publish_a_personal_tool(personal_client, monkeypatch, tmp_path):
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
assert create(personal_client, "alice").status_code == 200
|
|
name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
path = tmp_path / "deployment.json"
|
|
path.write_text(json.dumps({"mcpServers": {name: {"type": "http", "url": "https://example.com/platform"}}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(path))
|
|
platform = tag_mcp_tool(StructuredTool.from_function(lambda: "platform", name=name + "_test", description="Shared tool"), server_name=name)
|
|
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [platform])
|
|
|
|
async def discover(config, **kwargs):
|
|
async def personal():
|
|
return "personal"
|
|
|
|
return [tag_mcp_tool(StructuredTool.from_function(coroutine=personal, name=name + "_test", description="Personal tool"), server_name=name)]
|
|
|
|
monkeypatch.setattr("deerflow.mcp.tools.get_mcp_tools", discover)
|
|
config = SimpleNamespace(tools=[], models=[], get_model_config=lambda _: None)
|
|
identity = set_current_user(SimpleNamespace(id="alice"))
|
|
try:
|
|
tools = get_available_tools(app_config=config)
|
|
assert [tool for tool in tools if tool.name == name + "_test"] == [platform]
|
|
personal_id = read_user_mcp_config("alice")["mcpServers"]["github"]["capability"]["id"]
|
|
selected = get_available_tools(app_config=config, mcp_plugins=[personal_id])
|
|
assert not any(tool.name == name + "_test" for tool in selected)
|
|
finally:
|
|
reset_current_user(identity)
|
|
|
|
|
|
def test_untrusted_users_cannot_launch_packages_or_connect_to_private_hosts(personal_client, monkeypatch):
|
|
client = personal_client
|
|
response = client.post("/api/mcp/personal/config/servers", headers={"test-user": "alice", "test-role": "user"}, json={"mcp_servers": {"shell": {"type": "stdio", "command": "npx", "args": ["untrusted-package"]}}})
|
|
assert response.status_code == 403
|
|
response = client.post("/api/mcp/personal/config/servers", headers={"test-user": "alice", "test-role": "user"}, json={"mcp_servers": {"private": {"type": "http", "url": "http://127.0.0.1/mcp", "personal_public_network": False}}})
|
|
assert response.status_code == 400
|
|
monkeypatch.setattr("deerflow.community.url_safety.validate_public_http_url", lambda *a, **k: None)
|
|
assert create(client, "alice", role="user").status_code == 200
|
|
assert read_user_mcp_config("alice")["mcpServers"]["github"]["personal_public_network"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider,credentials",
|
|
[
|
|
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-secret"}),
|
|
("wecom", {"webhook_key": "webhook-secret"}),
|
|
("hubspot", {"access_token": "private-token"}),
|
|
],
|
|
)
|
|
def test_ordinary_users_install_and_manage_bundled_connections(personal_client, monkeypatch, tmp_path, provider, credentials):
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.routers import capabilities
|
|
from deerflow.capabilities.business import is_bundled_connection
|
|
from deerflow.mcp.personal_access import requires_admin
|
|
|
|
app = personal_client.app
|
|
app.include_router(capabilities.router)
|
|
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
|
|
platform = tmp_path / "deployment.json"
|
|
platform.write_text(json.dumps({"mcpServers": {}}))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(platform))
|
|
user = {"test-user": "alice", "test-role": "user"}
|
|
name = "my-" + provider
|
|
# The web UI installs every catalog connection with scope "user".
|
|
response = personal_client.post("/api/capabilities/installations", headers=user, json={"plugin_id": provider, "name": name, "scope": "user", "configuration": credentials})
|
|
assert response.status_code == 200, response.text
|
|
assert [item["name"] for item in response.json()["items"]] == [name]
|
|
for secret in credentials.values():
|
|
assert secret not in response.text
|
|
saved = next(iter(load_user_mcp_config("alice").mcp_servers.values()))
|
|
assert is_bundled_connection(saved.command, saved.args, saved.env)
|
|
assert set(saved.env.values()) == set(credentials.values())
|
|
# Saved under ordinary-user policy, so discovery and calls need no admin role.
|
|
assert not requires_admin(saved)
|
|
|
|
masked = personal_client.get("/api/mcp/personal/config", headers=user).json()["mcp_servers"][name]
|
|
masked["description"] = "Team notifications"
|
|
assert personal_client.put("/api/mcp/personal/config/server", headers=user, json={"server_name": name, "server": masked}).status_code == 200
|
|
for enabled in (False, True):
|
|
assert personal_client.patch("/api/mcp/personal/config", headers=user, json={"server_name": name, "enabled": enabled}).status_code == 200
|
|
saved = next(iter(load_user_mcp_config("alice").mcp_servers.values()))
|
|
assert saved.description == "Team notifications"
|
|
assert set(saved.env.values()) == set(credentials.values())
|
|
assert personal_client.delete("/api/mcp/personal/config/servers/" + name, headers=user).status_code == 200
|
|
assert read_user_mcp_config("alice")["mcpServers"] == {}
|
|
|
|
|
|
def test_bundled_launcher_with_a_working_directory_requires_an_administrator(personal_client):
|
|
from deerflow.capabilities.business import connection_config
|
|
|
|
definition = {**connection_config("hubspot", {"access_token": "private-token"}), "cwd": "/srv"}
|
|
user = {"test-user": "alice", "test-role": "user"}
|
|
response = personal_client.post("/api/mcp/personal/config/servers", headers=user, json={"mcp_servers": {"crm": definition}})
|
|
assert response.status_code == 403
|
|
assert read_user_mcp_config("alice")["mcpServers"] == {}
|
|
|
|
assert personal_client.post("/api/mcp/personal/config/servers", headers={"test-user": "alice"}, json={"mcp_servers": {"crm": definition}}).status_code == 200
|
|
before = user_mcp_config_path("alice").read_bytes()
|
|
masked = personal_client.get("/api/mcp/personal/config", headers=user).json()["mcp_servers"]["crm"]
|
|
assert masked["cwd"] == "/srv"
|
|
assert personal_client.put("/api/mcp/personal/config/server", headers=user, json={"server_name": "crm", "server": masked}).status_code == 403
|
|
assert personal_client.patch("/api/mcp/personal/config", headers=user, json={"server_name": "crm", "enabled": True}).status_code == 403
|
|
assert user_mcp_config_path("alice").read_bytes() == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_personal_network_rechecks_destination_before_each_request(monkeypatch):
|
|
from deerflow.mcp import personal_network
|
|
|
|
received = []
|
|
blocked = False
|
|
|
|
def transport(request):
|
|
received.append(str(request.url))
|
|
return httpx.Response(302, headers={"location": "http://127.0.0.1/private"})
|
|
|
|
monkeypatch.setattr(personal_network.httpx, "AsyncHTTPTransport", lambda **kwargs: httpx.MockTransport(transport))
|
|
monkeypatch.setattr(personal_network, "resolve_host_addresses", lambda host: [ipaddress.ip_address("127.0.0.1" if blocked else "8.8.8.8")])
|
|
async with personal_network.personal_httpx_client_factory() as client:
|
|
assert (await client.get("https://example.com/mcp")).status_code == 302
|
|
assert received == ["https://8.8.8.8/mcp"]
|
|
blocked = True
|
|
with pytest.raises(ValueError, match="public HTTP"):
|
|
await client.get("https://example.com/mcp")
|
|
assert len(received) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_mcp_calls_keep_credentials_separate_and_reject_stale_tools(personal_client, monkeypatch):
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
|
|
from deerflow.mcp import client as mcp_client
|
|
from deerflow.mcp.user_tools import _load
|
|
|
|
server = FastMCP("identity", stateless_http=True, json_response=True, transport_security=TransportSecuritySettings(allowed_hosts=["example.com"]))
|
|
received = []
|
|
|
|
@server.tool()
|
|
async def whoami(ctx: Context) -> str:
|
|
credential = ctx.request_context.request.headers.get("authorization")
|
|
received.append(credential)
|
|
return credential
|
|
|
|
app = server.streamable_http_app()
|
|
build = mcp_client.build_server_params
|
|
|
|
def params(name, config):
|
|
result = build(name, config)
|
|
result["httpx_client_factory"] = lambda headers=None, timeout=None, auth=None: httpx.AsyncClient(transport=httpx.ASGITransport(app=app), headers=headers, timeout=timeout or 30, auth=auth)
|
|
return result
|
|
|
|
monkeypatch.setattr(mcp_client, "build_server_params", params)
|
|
for user in ("alice", "bob"):
|
|
assert create(personal_client, user).status_code == 200
|
|
|
|
async def run(user):
|
|
identity = set_current_user(SimpleNamespace(id=user))
|
|
try:
|
|
tools = await _load(user, load_user_mcp_config(user))
|
|
assert len(tools) == 1
|
|
await tools[0].ainvoke({})
|
|
return tools[0]
|
|
finally:
|
|
reset_current_user(identity)
|
|
|
|
async with app.router.lifespan_context(app):
|
|
alice_tool, bob_tool = await asyncio.gather(run("alice"), run("bob"))
|
|
assert sorted(received) == ["Bearer alice", "Bearer bob"]
|
|
assert alice_tool.name != bob_tool.name
|
|
identity = set_current_user(SimpleNamespace(id="bob"))
|
|
try:
|
|
with pytest.raises(ToolException, match="another user"):
|
|
await alice_tool.ainvoke({})
|
|
assert personal_client.patch("/api/mcp/personal/config", headers={"test-user": "bob"}, json={"server_name": "github", "enabled": False}).status_code == 200
|
|
with pytest.raises(ToolException, match="changed, disabled or removed"):
|
|
await bob_tool.ainvoke({})
|
|
assert len(received) == 2
|
|
finally:
|
|
reset_current_user(identity)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_personal_tool_guard_reuses_validation_and_rejects_file_replacement(personal_client, monkeypatch):
|
|
import os
|
|
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
import deerflow.mcp.user_config as user_config
|
|
from deerflow.config.extensions_config import atomic_write_extensions_config
|
|
from deerflow.mcp.user_tools import _guard
|
|
|
|
assert create(personal_client, "alice").status_code == 200
|
|
old_name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
load = user_config.load_user_mcp_config
|
|
reads = 0
|
|
|
|
def counted_load(user_id):
|
|
nonlocal reads
|
|
reads += 1
|
|
return load(user_id)
|
|
|
|
monkeypatch.setattr(user_config, "load_user_mcp_config", counted_load)
|
|
calls = []
|
|
|
|
async def invoke():
|
|
calls.append(True)
|
|
return "ok"
|
|
|
|
tool = _guard(StructuredTool.from_function(coroutine=invoke, name="personal_test", description="Personal test"), "alice", old_name)
|
|
identity = set_current_user(SimpleNamespace(id="alice"))
|
|
try:
|
|
assert await tool.ainvoke({}) == "ok"
|
|
assert await tool.ainvoke({}) == "ok"
|
|
assert reads == 1
|
|
|
|
path = user_mcp_config_path("alice")
|
|
before = path.stat()
|
|
raw = read_user_mcp_config("alice")
|
|
raw["mcpServers"]["github"]["headers"]["Authorization"] = "Bearer ALICE"
|
|
atomic_write_extensions_config(path, raw)
|
|
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
|
after = path.stat()
|
|
assert (after.st_size, after.st_mtime_ns) == (before.st_size, before.st_mtime_ns)
|
|
assert after.st_ino != before.st_ino
|
|
with pytest.raises(ToolException, match="changed, disabled or removed"):
|
|
await tool.ainvoke({})
|
|
assert reads == 2
|
|
assert len(calls) == 2
|
|
finally:
|
|
reset_current_user(identity)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("parameter", ["runtime", "deerflow_runtime"])
|
|
async def test_personal_tool_guard_reads_the_injected_runtime_for_either_transport(personal_client, parameter):
|
|
"""HTTP/SSE tools keep the adapter's ``runtime``; pooled stdio uses ``deerflow_runtime``."""
|
|
from typing import Annotated
|
|
|
|
from langchain_core.messages import AIMessage
|
|
from langchain_core.tools import InjectedToolArg, StructuredTool
|
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
|
|
from deerflow.mcp.user_tools import _guard
|
|
from deerflow.tools.types import Runtime
|
|
|
|
assert create(personal_client, "alice").status_code == 200
|
|
server_name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
calls = []
|
|
|
|
async def invoke_runtime(runtime: Annotated[Runtime | None, InjectedToolArg()] = None, **arguments):
|
|
calls.append(arguments)
|
|
return "ok"
|
|
|
|
async def invoke_deerflow_runtime(deerflow_runtime: Runtime | None = None, **arguments):
|
|
calls.append(arguments)
|
|
return "ok"
|
|
|
|
coroutine = invoke_runtime if parameter == "runtime" else invoke_deerflow_runtime
|
|
tool = _guard(
|
|
StructuredTool(name="personal_test", description="Personal test", args_schema={"type": "object", "properties": {"runtime": {"type": "string"}}}, coroutine=coroutine),
|
|
"alice",
|
|
server_name,
|
|
)
|
|
builder = StateGraph(MessagesState, context_schema=dict)
|
|
builder.add_node("tools", ToolNode([tool]))
|
|
builder.add_edge(START, "tools")
|
|
builder.add_edge("tools", END)
|
|
graph = builder.compile()
|
|
message = AIMessage(content="", tool_calls=[{"name": "personal_test", "args": {"runtime": "python"}, "id": "call-1", "type": "tool_call"}])
|
|
|
|
# No ambient user: only the injected runtime says who is calling.
|
|
result = await graph.ainvoke({"messages": [message]}, context={"user_id": "alice"})
|
|
assert result["messages"][-1].content == "ok"
|
|
assert calls == [{"runtime": "python"} if parameter == "deerflow_runtime" else {}]
|
|
|
|
with pytest.raises(ToolException, match="belongs to another user"):
|
|
await graph.ainvoke({"messages": [message]}, context={"user_id": "bob"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_calls_resolve_only_persisted_task_owner(personal_client, monkeypatch):
|
|
import deerflow.mcp.user_config as user_config
|
|
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
|
|
|
|
create(personal_client, "alice")
|
|
name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
received = []
|
|
loaded = []
|
|
load = user_config.load_user_mcp_config
|
|
|
|
def counted_load(user_id):
|
|
loaded.append(user_id)
|
|
return load(user_id)
|
|
|
|
monkeypatch.setattr(user_config, "load_user_mcp_config", counted_load)
|
|
|
|
async def invoke(self, **kwargs):
|
|
received.append(self._extensions_config.mcp_servers[kwargs["server_name"]].headers["Authorization"])
|
|
|
|
monkeypatch.setattr(McpTaskToolCaller, "_call_configured_tool", invoke)
|
|
caller = McpTaskToolCaller(ExtensionsConfig())
|
|
await caller.call_tool(server_name=name, tool_name="status", arguments={}, user_id="alice", thread_id="thread", connection_scope="personal")
|
|
first_caller = caller._personal_callers["alice"][1]
|
|
await caller.call_tool(server_name=name, tool_name="status", arguments={}, user_id="alice", thread_id="thread", connection_scope="personal")
|
|
assert caller._personal_callers["alice"][1] is first_caller
|
|
assert loaded == ["alice"]
|
|
with pytest.raises(LookupError, match="Personal MCP"):
|
|
await caller.call_tool(server_name=name, tool_name="status", arguments={}, user_id="bob", thread_id="thread", connection_scope="personal")
|
|
assert received == ["Bearer alice", "Bearer alice"]
|
|
|
|
# The same deployment name must not steal an existing personal task after
|
|
# the Gateway restarts with that deployment entry in its startup snapshot.
|
|
deployment = McpTaskToolCaller(ExtensionsConfig.model_validate({"mcpServers": {name: {"type": "http", "url": "https://example.com/platform", "headers": {"Authorization": "platform"}}}}))
|
|
await deployment.call_tool(server_name=name, tool_name="status", arguments={}, user_id="alice", thread_id="thread", connection_scope="personal")
|
|
await deployment.call_tool(server_name=name, tool_name="status", arguments={}, user_id="bob", thread_id="thread")
|
|
assert received == ["Bearer alice", "Bearer alice", "Bearer alice", "platform"]
|
|
assert personal_client.patch("/api/mcp/personal/config", headers={"test-user": "alice"}, json={"server_name": "github", "enabled": False}).status_code == 200
|
|
with pytest.raises(LookupError, match="Personal MCP"):
|
|
await deployment.call_tool(server_name=name, tool_name="status", arguments={}, user_id="alice", thread_id="thread", connection_scope="personal")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_caller_rebuilds_after_personal_credential_edit(personal_client, monkeypatch):
|
|
from deerflow.config.extensions_config import atomic_write_extensions_config
|
|
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
|
|
|
|
assert create(personal_client, "alice").status_code == 200
|
|
old_name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
|
|
async def credential(self, **kwargs):
|
|
return self._extensions_config.mcp_servers[kwargs["server_name"]].headers["Authorization"]
|
|
|
|
monkeypatch.setattr(McpTaskToolCaller, "_call_configured_tool", credential)
|
|
caller = McpTaskToolCaller(ExtensionsConfig())
|
|
request = {"tool_name": "status", "arguments": {}, "user_id": "alice", "thread_id": "thread", "connection_scope": "personal"}
|
|
assert await caller.call_tool(server_name=old_name, **request) == "Bearer alice"
|
|
previous = caller._personal_callers["alice"][1]
|
|
|
|
path = user_mcp_config_path("alice")
|
|
raw = read_user_mcp_config("alice")
|
|
raw["mcpServers"]["github"]["headers"]["Authorization"] = "Bearer ALICE"
|
|
atomic_write_extensions_config(path, raw)
|
|
with pytest.raises(LookupError, match="Personal MCP"):
|
|
await caller.call_tool(server_name=old_name, **request)
|
|
new_name = next(iter(load_user_mcp_config("alice").mcp_servers))
|
|
assert await caller.call_tool(server_name=new_name, **request) == "Bearer ALICE"
|
|
assert caller._personal_callers["alice"][1] is not previous
|
|
path.unlink()
|
|
with pytest.raises(LookupError, match="Personal MCP"):
|
|
await caller.call_tool(server_name=new_name, **request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_caller_keeps_only_recent_owners(personal_client, monkeypatch):
|
|
import deerflow.mcp.task_tool_caller as task_tool_caller
|
|
|
|
monkeypatch.setattr(task_tool_caller, "_MAX_PERSONAL_CALLERS", 2)
|
|
|
|
async def credential(self, **kwargs):
|
|
return self._extensions_config.mcp_servers[kwargs["server_name"]].headers["Authorization"]
|
|
|
|
monkeypatch.setattr(task_tool_caller.McpTaskToolCaller, "_call_configured_tool", credential)
|
|
caller = task_tool_caller.McpTaskToolCaller(ExtensionsConfig())
|
|
names = {}
|
|
for user_id in ("alice", "bob", "carol"):
|
|
assert create(personal_client, user_id).status_code == 200
|
|
names[user_id] = next(iter(load_user_mcp_config(user_id).mcp_servers))
|
|
assert await caller.call_tool(server_name=names[user_id], tool_name="status", arguments={}, user_id=user_id, thread_id="thread", connection_scope="personal") == f"Bearer {user_id}"
|
|
assert list(caller._personal_callers) == ["bob", "carol"]
|
|
assert await caller.call_tool(server_name=names["alice"], tool_name="status", arguments={}, user_id="alice", thread_id="thread", connection_scope="personal") == "Bearer alice"
|
|
assert list(caller._personal_callers) == ["carol", "alice"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_registers_driver_for_personal_only_task_toolsets(personal_client):
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from app.gateway.app import lifespan
|
|
from deerflow.config.extensions_config import atomic_write_extensions_config
|
|
from deerflow.config.mcp_tasks_config import McpTasksConfig
|
|
from deerflow.mcp.tasks import ORDINARY_MCP_TASK_DRIVER
|
|
from deerflow.mcp.tools import get_mcp_tools
|
|
|
|
path = user_mcp_config_path("alice")
|
|
atomic_write_extensions_config(
|
|
path,
|
|
{"mcpServers": {"reports": {"type": "http", "url": "https://example.com/mcp", "task_toolsets": [{"name": "reports", "submit_tool": "submit_report", "status_tool": "status_report", "cancel_tool": "cancel_report"}]}}},
|
|
)
|
|
personal = load_user_mcp_config("alice")
|
|
server_name = next(iter(personal.mcp_servers))
|
|
deployment = ExtensionsConfig()
|
|
startup = SimpleNamespace(log_level="INFO", memory=SimpleNamespace(enabled=False, token_counting="char", shutdown_flush_timeout_seconds=5.0), mcp_tasks=McpTasksConfig(enabled=True))
|
|
app = FastAPI()
|
|
|
|
@asynccontextmanager
|
|
async def runtime(gateway, _config):
|
|
gateway.state.mcp_task_repo = object()
|
|
yield
|
|
|
|
class FakeClient:
|
|
def __init__(self, _servers, *, tool_interceptors, **_kwargs):
|
|
self.tool_interceptors = tool_interceptors
|
|
self.callbacks = None
|
|
|
|
async def get_tools(self, *, server_name):
|
|
async def call(topic: str) -> str:
|
|
return topic
|
|
|
|
return [StructuredTool.from_function(coroutine=call, name=f"{server_name}_{name}", description=name) for name in ("submit_report", "status_report", "cancel_report")]
|
|
|
|
channel = MagicMock()
|
|
channel.get_status.return_value = {}
|
|
with (
|
|
patch("app.gateway.app.get_app_config", return_value=startup),
|
|
patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)),
|
|
patch("app.gateway.app.langgraph_runtime", runtime),
|
|
patch("app.gateway.app.auth.close_oidc_service", AsyncMock()),
|
|
patch("app.channels.service.start_channel_service", AsyncMock(return_value=channel)),
|
|
patch("app.channels.service.stop_channel_service", AsyncMock()),
|
|
patch("deerflow.skills.projection.ensure_public_skill_projection"),
|
|
patch("deerflow.agents.memory.get_memory_manager", return_value=MagicMock()),
|
|
patch("deerflow.config.extensions_config.ExtensionsConfig.from_file", return_value=deployment),
|
|
patch("app.mcp_tasks.McpTaskService.start", AsyncMock()),
|
|
patch("app.mcp_tasks.McpTaskService.stop", AsyncMock()),
|
|
patch("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient),
|
|
):
|
|
async with lifespan(app):
|
|
assert not deployment.mcp_servers
|
|
assert app.state.mcp_task_service.drivers.get(ORDINARY_MCP_TASK_DRIVER) is not None
|
|
tools = await get_mcp_tools(personal, personal_user_id="alice")
|
|
assert [tool.name for tool in tools] == [f"{server_name}_submit_report"]
|
|
app.state.mcp_task_service.submit = AsyncMock(return_value={"id": "local-1", "status": "submitted"})
|
|
identity = set_current_user(SimpleNamespace(id="alice"))
|
|
try:
|
|
result = await tools[0].coroutine(
|
|
deerflow_runtime=SimpleNamespace(context={"thread_id": "thread-1", "thread_incarnation": "incarnation-1", "run_id": "run-1"}, config={}, tool_call_id="call-1"),
|
|
topic="MCP",
|
|
)
|
|
finally:
|
|
reset_current_user(identity)
|
|
assert result["task_id"] == "local-1"
|
|
submitted = app.state.mcp_task_service.submit.await_args.kwargs
|
|
assert submitted["driver_name"] == ORDINARY_MCP_TASK_DRIVER
|
|
assert submitted["request"].driver_data["connection_scope"] == "personal"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreachable_personal_task_server_keeps_other_personal_tools(personal_client):
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from deerflow.config.extensions_config import atomic_write_extensions_config
|
|
from deerflow.mcp.user_tools import _load
|
|
|
|
toolset = {"name": "reports", "submit_tool": "submit_report", "status_tool": "status_report", "cancel_tool": "cancel_report"}
|
|
atomic_write_extensions_config(
|
|
user_mcp_config_path("alice"),
|
|
{
|
|
"mcpServers": {
|
|
"notes": {"type": "http", "url": "https://notes.example.com/mcp"},
|
|
"reports": {"type": "http", "url": "https://reports.example.com/mcp", "task_toolsets": [toolset]},
|
|
}
|
|
},
|
|
)
|
|
|
|
class FakeClient:
|
|
def __init__(self, servers, *, tool_interceptors, **_kwargs):
|
|
self.servers = servers
|
|
self.tool_interceptors = tool_interceptors
|
|
self.callbacks = None
|
|
|
|
async def get_tools(self, *, server_name):
|
|
if "reports" in self.servers[server_name]["url"]:
|
|
raise ConnectionError("reports is down")
|
|
return [StructuredTool.from_function(lambda: "ok", name=f"{server_name}_search", description="search")]
|
|
|
|
with (
|
|
patch("deerflow.mcp.tasks.runtime.is_mcp_task_runtime_available", return_value=True),
|
|
patch("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient),
|
|
):
|
|
personal = load_user_mcp_config("alice")
|
|
tools = await _load("alice", personal)
|
|
|
|
notes_name = next(name for name, server in personal.mcp_servers.items() if server.url == "https://notes.example.com/mcp")
|
|
assert [tool.name for tool in tools] == [f"{notes_name}_search"]
|
|
|
|
|
|
@pytest.fixture
|
|
def stored_oauth_rows(tmp_path):
|
|
"""Install a shared OAuth token store; yields a ``(seed, keys)`` pair over its rows."""
|
|
import sqlalchemy as sa
|
|
from cryptography.fernet import Fernet
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from deerflow.config.credentials_key import CredentialsCipher
|
|
from deerflow.mcp.oauth_store import McpOAuthTokenStore, set_mcp_oauth_token_store
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.mcp_oauth_tokens import McpOAuthTokenRow, SqlMcpOAuthTokenRepository
|
|
|
|
engine = sa.create_engine(f"sqlite:///{(tmp_path / 'oauth-tokens.db').as_posix()}")
|
|
Base.metadata.create_all(engine, tables=[McpOAuthTokenRow.__table__])
|
|
repo = SqlMcpOAuthTokenRepository(sessionmaker(engine, expire_on_commit=False))
|
|
previous = set_mcp_oauth_token_store(McpOAuthTokenStore(repo, cipher=CredentialsCipher.from_keys([Fernet.generate_key()])))
|
|
|
|
def seed(owner, server_key, scope="personal"):
|
|
assert repo.claim(scope, owner, server_key, config_fingerprint="f" * 64, expected_version=None, lease_owner="seed", lease_seconds=1.0, now=0.0)
|
|
|
|
def keys(owner, scope="personal"):
|
|
with engine.connect() as conn:
|
|
return set(conn.scalars(sa.select(McpOAuthTokenRow.server_key).where(McpOAuthTokenRow.scope == scope, McpOAuthTokenRow.owner_user_id == owner)))
|
|
|
|
try:
|
|
yield seed, keys
|
|
finally:
|
|
set_mcp_oauth_token_store(previous)
|
|
engine.dispose()
|
|
|
|
|
|
def test_removed_or_edited_connections_drop_their_stored_oauth_tokens(personal_client, stored_oauth_rows):
|
|
"""A removed or edited connection's runtime name never returns, so its token row would only hold ciphertext forever."""
|
|
seed, keys = stored_oauth_rows
|
|
client = personal_client
|
|
create(client, "alice", name="github")
|
|
create(client, "alice", name="linear")
|
|
create(client, "bob", name="github")
|
|
alice = set(load_user_mcp_config("alice").mcp_servers)
|
|
bob = set(load_user_mcp_config("bob").mcp_servers)
|
|
for name in alice:
|
|
seed("alice", name)
|
|
for name in bob:
|
|
seed("bob", name)
|
|
seed("alice", "personal_left-by-an-earlier-edit")
|
|
seed("", "github", scope="deployment")
|
|
github = personal_server_name("alice", "github", read_user_mcp_config("alice")["mcpServers"]["github"])
|
|
assert github in alice
|
|
|
|
# Turning a connection off and on restores its runtime name: its chain survives.
|
|
for enabled in (False, True):
|
|
result = client.patch("/api/mcp/personal/config", headers={"test-user": "alice"}, json={"server_name": "github", "enabled": enabled})
|
|
assert result.status_code == 200, result.text
|
|
assert keys("alice") == alice, "a toggle must keep the stored chain; an earlier orphan is swept"
|
|
|
|
result = client.put("/api/mcp/personal/config/server", headers={"test-user": "alice"}, json={"server_name": "linear", "server": {"type": "http", "url": "https://example.com/linear-v2", "headers": {"Authorization": "***"}}})
|
|
assert result.status_code == 200, result.text
|
|
assert keys("alice") == {github}, "the edited connection's old row is gone"
|
|
|
|
assert client.delete("/api/mcp/personal/config/servers/github", headers={"test-user": "alice"}).status_code == 200
|
|
assert keys("alice") == set()
|
|
assert keys("bob") == bob, "another owner's rows are untouched"
|
|
assert keys("", scope="deployment") == {"github"}, "deployment rows are untouched"
|
|
|
|
|
|
def test_personal_config_save_survives_an_unavailable_token_store(personal_client, monkeypatch):
|
|
from deerflow.mcp.oauth_store import McpOAuthTokenStoreUnavailable, set_mcp_oauth_token_store
|
|
|
|
store = MagicMock()
|
|
store.forget_unconfigured.side_effect = McpOAuthTokenStoreUnavailable("down")
|
|
previous = set_mcp_oauth_token_store(store)
|
|
try:
|
|
assert create(personal_client, "alice").status_code == 200
|
|
finally:
|
|
set_mcp_oauth_token_store(previous)
|
|
assert "github" in read_user_mcp_config("alice")["mcpServers"]
|
|
store.forget_unconfigured.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("failure_stage", ["resolve", "read", "write"])
|
|
@pytest.mark.parametrize(
|
|
("method", "url", "body", "expected_detail"),
|
|
[
|
|
("POST", "/api/mcp/personal/config/servers", {"mcp_servers": {"new": {"type": "http", "url": "https://example.com/new"}}}, "Failed to add personal MCP servers"),
|
|
("PUT", "/api/mcp/personal/config/server", {"server_name": "github", "server": {"type": "http", "url": "https://example.com/updated"}}, "Failed to update personal MCP server"),
|
|
("PATCH", "/api/mcp/personal/config", {"server_name": "github", "enabled": False}, "Failed to update personal MCP server state"),
|
|
("DELETE", "/api/mcp/personal/config/servers/github", None, "Failed to delete personal MCP server"),
|
|
],
|
|
ids=["create", "update", "state", "delete"],
|
|
)
|
|
def test_personal_mutation_storage_errors_have_fixed_details_and_server_logs(personal_client, monkeypatch, caplog, failure_stage, method, url, body, expected_detail):
|
|
assert create(personal_client, "alice").status_code == 200
|
|
path = user_mcp_config_path("alice")
|
|
before = path.read_bytes()
|
|
host_path = "/srv/private/deer-flow/users/alice/extensions_config.json"
|
|
error = PermissionError(13, "Permission denied", host_path)
|
|
|
|
def fail(*_args, **_kwargs):
|
|
raise error
|
|
|
|
boundary = {"resolve": "user_mcp_config_path", "read": "read_user_mcp_config", "write": "atomic_write_extensions_config"}[failure_stage]
|
|
monkeypatch.setattr(personal_mcp, boundary, fail)
|
|
with TestClient(personal_client.app, raise_server_exceptions=False) as client:
|
|
with caplog.at_level(logging.ERROR, logger="app.gateway.routers.personal_mcp"):
|
|
response = client.request(method, url, headers={"test-user": "alice"}, json=body)
|
|
|
|
assert response.status_code == 500
|
|
assert response.headers["content-type"].startswith("application/json"), response.text
|
|
assert response.json() == {"detail": expected_detail}
|
|
assert host_path not in response.text
|
|
assert path.read_bytes() == before
|
|
records = [record for record in caplog.records if record.name == "app.gateway.routers.personal_mcp"]
|
|
assert len(records) == 1
|
|
assert records[0].exc_info[1] is error
|
|
assert host_path in caplog.text
|