fix(mcp): isolate personal connections from deployment configuration (#5966)

* fix(mcp): isolate personal connections from deployment configuration

* fix(mcp): preserve personal task ownership and repair plugin E2E fixtures

* fix(mcp): handle corrupt personal config and cache owned calls

* fix(mcp): pin personal endpoint addresses and handle invalid listings

* fix(mcp): recheck current authority for privileged personal connections
This commit is contained in:
Wenchao An 2026-09-28 16:02:34 +08:00 committed by GitHub
parent a3d75e843f
commit 52a3e2564f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1893 additions and 167 deletions

View File

@ -645,6 +645,9 @@ Signed-in users' notification toggle, default model, conversation mode, and reas
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
Personal MCP connections configured in the web interface are persisted per user.
Deployment tools remain shared. See [connection ownership](docs/capability-center.md#personal-and-deployment-mcp-configuration).
For plugin manifests, adapter registration, and Agent capability selection, see
[Capability Center integration contract](docs/capability-center.md).
@ -655,7 +658,7 @@ contacts. Saving configuration performs no external write. These plugins reuse
the existing MCP lifecycle and require no separate plugin service. See the
integration contract above for required fields, scopes, and feature boundaries.
Plugin brand icons are bundled locally. When adding or editing one MCP plugin, administrators can upload a PNG, JPG, or WebP image (up to 2 MB), preview it, or restore the default icon. Changes take effect only after Save; custom icons persist across browsers as a normalized 128px PNG in the server entry's display-only `presentation.icon` metadata. They are not sent to the MCP transport.
Plugin brand icons are bundled locally. When adding or editing one personal MCP plugin, users can upload a PNG, JPG, or WebP image (up to 2 MB), preview it, or restore the default icon. Changes take effect only after Save; custom icons persist across browsers as a normalized 128px PNG in the server entry's display-only `presentation.icon` metadata. They are not sent to the MCP transport.
Capability Center > Plugins adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.

View File

@ -211,10 +211,9 @@ archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LI
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.
Capability installation IDs must be unique for MCP create/replace/state writes.
Single-server DELETE keeps schema validation but allows residual identity
collisions, so legacy duplicate groups can be repaired incrementally. Runtime
explicit selections still exclude ambiguous identities until repaired.
MCP create/replace/state writes require unique installation IDs. DELETE validates
schema but permits residual legacy ID collisions. Explicit selections exclude
ambiguous IDs until repaired.
Capability skill discovery (`/api/capabilities/installations/skills`) reuses
`routers/skills.py::_filter_visible_skills` after its off-thread storage read.
@ -234,3 +233,5 @@ model, while provider failures honor `authorization.fail_closed`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.
Batch workers pin `app.state.extensions`; never persist snapshots.
Personal MCP uses owner-only files/calls; platform MCP stays shared.

View File

@ -40,6 +40,7 @@ from app.gateway.routers import (
mcp_tasks,
memory,
models,
personal_mcp,
plugins,
project_documents,
project_thread_files,
@ -526,7 +527,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.warning("Upload staging file cleanup skipped", exc_info=True)
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
async with _runtime_with_mcp_pool_shutdown(app, startup_config):
from app.gateway.personal_mcp_access import personal_mcp_authority
async with personal_mcp_authority(), _runtime_with_mcp_pool_shutdown(app, startup_config):
logger.info("LangGraph runtime initialised")
# Check admin bootstrap state and migrate orphan threads after admin exists.
@ -603,7 +606,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
OrdinaryMcpTaskDriver,
)
from deerflow.mcp.tasks.runtime import (
configured_task_toolset_count,
set_mcp_task_config_snapshot,
set_mcp_task_submitter,
validate_mcp_task_runtime_configuration,
@ -622,11 +624,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
)
if mcp_task_repo is not None:
mcp_task_drivers = McpTaskDriverRegistry()
if configured_task_toolset_count(task_extensions_config):
mcp_task_drivers.register(
ORDINARY_MCP_TASK_DRIVER,
OrdinaryMcpTaskDriver(McpTaskToolCaller(task_extensions_config)),
)
mcp_task_drivers.register(
ORDINARY_MCP_TASK_DRIVER,
OrdinaryMcpTaskDriver(McpTaskToolCaller(task_extensions_config)),
)
mcp_task_service = McpTaskService(
repository=mcp_task_repo,
drivers=mcp_task_drivers,
@ -1053,6 +1054,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# MCP API is mounted at /api/mcp
app.include_router(capabilities.router)
app.include_router(mcp.router)
app.include_router(personal_mcp.router)
# Durable MCP tasks are scoped to their owning thread.
app.include_router(mcp_tasks.router)

View File

@ -2,13 +2,13 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Protocol
from typing import Any, Literal, Protocol
from uuid import uuid4
from fastapi import HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from app.gateway.deps import is_admin_user
from app.gateway.deps import get_current_user_from_request, is_admin_user
from app.gateway.routers import integrations, mcp, skills
from deerflow.capabilities.business import connection_config
from deerflow.capabilities.catalog import PluginManifest
@ -46,6 +46,7 @@ class AdapterContext:
request: Request
config: AppConfig
user_id: str
scope: Literal["deployment", "user"] = "deployment"
class CapabilityAdapter(Protocol):
@ -87,7 +88,16 @@ def validate_mcp_connection(configuration: dict[str, Any]) -> None:
class MCPAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
servers = await asyncio.to_thread(mcp._load_raw_mcp_server_responses)
if context.scope == "user":
from deerflow.mcp.user_config import read_user_mcp_config
try:
raw_config = await asyncio.to_thread(read_user_mcp_config, context.user_id)
except ValueError as exc:
mcp._raise_invalid_mcp_configuration(str(exc), cause=exc)
servers = mcp._mcp_server_responses_from_raw(raw_config)
else:
servers = await asyncio.to_thread(mcp._load_raw_mcp_server_responses)
result = []
ambiguous = ambiguous_installation_ids({name: server.model_dump() for name, server in servers.items()})
for name, server in servers.items():
@ -113,6 +123,7 @@ class MCPAdapter:
health="ambiguous" if identity in ambiguous else "unknown",
plugin_id=metadata.get("plugin_id") if isinstance(metadata.get("plugin_id"), str) else None,
adapter="mcp",
scope=context.scope,
name=name,
reference=name,
description=server.description or "",
@ -134,7 +145,12 @@ class MCPAdapter:
body = mcp.McpConfigUpdateRequest(mcp_servers={name: mcp.McpServerConfigResponse.model_validate(definition)})
except ValidationError as error:
raise HTTPException(422, "Invalid MCP configuration") from error
await mcp.create_mcp_servers(context.request, body)
if context.scope == "user":
from app.gateway.routers.personal_mcp import create_servers
await create_servers(context.request, body)
else:
await mcp.create_mcp_servers(context.request, body)
class BusinessAdapter(MCPAdapter):
@ -222,7 +238,12 @@ registry.register("lark", LarkAdapter())
registry.register("skills", SkillAdapter())
async def list_installations(adapter: str, request: Request, config: AppConfig) -> InstallationList:
context = AdapterContext(request, config, get_effective_user_id())
async def list_installations(adapter: str, request: Request, config: AppConfig, scope: Literal["deployment", "user", "all"] = "deployment") -> InstallationList:
user_id = get_effective_user_id()
if scope != "deployment":
user_id = str((await get_current_user_from_request(request)).id)
context = AdapterContext(request, config, user_id, "user" if scope == "user" else "deployment")
items = await registry.get(adapter).list_installations(context)
return InstallationList(items=items, can_manage=await is_admin_user(request))
if scope == "all" and adapter in {"mcp", "business"}:
items += await registry.get(adapter).list_installations(AdapterContext(request, config, user_id, "user"))
return InstallationList(items=items, can_manage=scope == "user" or await is_admin_user(request))

View File

@ -0,0 +1,33 @@
"""Bind personal MCP authority to fresh Gateway account records."""
import asyncio
from contextlib import asynccontextmanager
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
from app.gateway.deps import get_local_provider
from deerflow.mcp.personal_access import set_personal_mcp_admin_checker
async def _is_current_admin(owner: str) -> bool:
if is_auth_disabled() and owner == AUTH_DISABLED_USER_ID:
return True
user = await get_local_provider().get_user(owner)
return user is not None and user.system_role == "admin"
@asynccontextmanager
async def personal_mcp_authority():
loop = asyncio.get_running_loop()
async def check(owner: str) -> bool:
if asyncio.get_running_loop() is loop:
return await _is_current_admin(owner)
# Synchronous tool discovery runs on assembly workers with their own
# loops. Keep the shared database pool on the Gateway's owning loop.
return await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(_is_current_admin(owner), loop))
previous = set_personal_mcp_admin_checker(check)
try:
yield
finally:
set_personal_mcp_admin_checker(previous)

View File

@ -1,13 +1,13 @@
"""Capability discovery is public to authenticated users; mutations reuse existing policy."""
import asyncio
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.capabilities import AdapterContext, InstallationList, list_installations, registry
from app.gateway.deps import get_config, require_admin_user
from app.gateway.deps import get_config, get_current_user_from_request, require_admin_user
from deerflow.capabilities.catalog import PluginManifest, load_catalog
from deerflow.config.app_config import AppConfig
from deerflow.runtime.user_context import get_effective_user_id
@ -20,6 +20,7 @@ class InstallRequest(BaseModel):
plugin_id: str
name: str = Field(default="", max_length=128)
configuration: dict[str, Any] = Field(default_factory=dict)
scope: Literal["deployment", "user"] = "deployment"
@router.get("/catalog", response_model=list[PluginManifest])
@ -28,17 +29,23 @@ async def catalog() -> list[PluginManifest]:
@router.get("/installations/{adapter}", response_model=InstallationList)
async def installations(adapter: str, request: Request, config: AppConfig = Depends(get_config)) -> InstallationList:
return await list_installations(adapter, request, config)
async def installations(adapter: str, request: Request, config: AppConfig = Depends(get_config), scope: Literal["deployment", "user", "all"] = "deployment") -> InstallationList:
return await list_installations(adapter, request, config, scope)
@router.post("/installations", response_model=InstallationList)
async def install(body: InstallRequest, request: Request, config: AppConfig = Depends(get_config)) -> InstallationList:
await require_admin_user(request, detail="Admin privileges required to install capabilities.")
user_id = get_effective_user_id()
if body.scope == "deployment":
await require_admin_user(request, detail="Admin privileges required to install capabilities.")
else:
user_id = str((await get_current_user_from_request(request)).id)
entries = await asyncio.to_thread(load_catalog)
manifest = next((entry for entry in entries if entry.id == body.plugin_id), None)
if manifest is None:
raise HTTPException(404, "Plugin not found")
context = AdapterContext(request, config, get_effective_user_id())
if body.scope == "user" and manifest.adapter not in {"mcp", "business"}:
await require_admin_user(request, detail="Admin privileges required to install this capability.")
context = AdapterContext(request, config, user_id, body.scope)
await registry.get(manifest.adapter).install(context, manifest, body.name, body.configuration)
return await list_installations(manifest.adapter, request, config)
return await list_installations(manifest.adapter, request, config, body.scope)

View File

@ -0,0 +1,126 @@
"""Owner-only MCP configuration. Deployment MCP routes remain admin-only."""
import asyncio
from typing import Literal
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import ValidationError
from app.gateway.deps import get_current_user_from_request, is_admin_user
from app.gateway.routers import mcp
from deerflow.capabilities.runtime import ambiguous_installation_ids
from deerflow.config.extensions_config import ExtensionsConfig, atomic_write_extensions_config, extensions_config_file_lock, extensions_config_write_lock
from deerflow.mcp.user_config import read_user_mcp_config, user_mcp_config_path
router = APIRouter(prefix="/api/mcp/personal/config", tags=["mcp"])
async def _owner(request: Request) -> str:
user = await get_current_user_from_request(request)
return str(user.id)
def _response(raw: dict) -> mcp.McpConfigResponse:
return mcp.McpConfigResponse(mcp_servers={name: mcp._mask_server_config(server) for name, server in mcp._mcp_server_responses_from_raw(raw).items()})
def _read_personal_config(user_id: str) -> dict:
try:
return read_user_mcp_config(user_id)
except ValueError as exc:
mcp._raise_invalid_mcp_configuration(str(exc), cause=exc)
def _validate_personal_server(server: mcp.McpServerConfigResponse, *, admin: bool) -> None:
# A personal store must not grant ordinary users the deployment operator's
# ability to launch arbitrary host packages or query internal services.
if not admin:
from deerflow.capabilities.business import is_bundled_connection
bundled = server.type == "stdio" and is_bundled_connection(server.command, server.args, server.env) and not server.cwd
if (server.type not in {"http", "sse"} and not bundled) or server.oauth is not None:
raise HTTPException(403, "Only an administrator may configure host commands or OAuth token endpoints")
from deerflow.community.url_safety import validate_public_http_url
if not bundled and (not server.url or validate_public_http_url(server.url, action="connect to")):
raise HTTPException(400, "Personal MCP connections require a public HTTP(S) endpoint")
mcp._validate_mcp_update_request(mcp.McpConfigUpdateRequest(mcp_servers={"personal": server}))
def _mutate(user_id: str, operation: Literal["create", "update", "delete", "state"], body, *, admin: bool) -> dict:
path = user_mcp_config_path(user_id)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with extensions_config_write_lock, extensions_config_file_lock(path):
raw = _read_personal_config(user_id)
servers = mcp._raw_mcp_servers(raw)
if operation == "create":
for name, incoming in body.mcp_servers.items():
if name in servers:
raise HTTPException(409, f"MCP server '{name}' already exists")
mcp._ensure_no_masked_secrets(incoming)
_validate_personal_server(incoming, admin=admin)
definition = incoming.model_dump()
metadata = definition.get("capability")
definition["capability"] = {**(metadata if isinstance(metadata, dict) else {}), "id": str(uuid4())}
definition["personal_public_network"] = not admin
servers[name] = definition
else:
name = body if operation == "delete" else body.server_name
if name not in servers:
raise HTTPException(404, "MCP server not found")
if operation == "delete":
del servers[name]
elif operation == "update":
merged = mcp._merge_preserving_secrets(body.server, mcp._mcp_server_response_from_raw(name, servers[name]), preserve_omitted_fields=False)
_validate_personal_server(merged, admin=admin)
servers[name] = {**merged.model_dump(), "personal_public_network": not admin}
else:
if body.enabled:
_validate_personal_server(mcp._mcp_server_response_from_raw(name, servers[name]), admin=admin)
servers[name]["personal_public_network"] = not admin
servers[name]["enabled"] = body.enabled
candidate = {"mcpServers": servers}
try:
# No environment expansion: these are caller-owned literal values.
ExtensionsConfig.model_validate(candidate)
except ValidationError:
raise HTTPException(400, "Invalid personal MCP configuration") from None
if ambiguous_installation_ids(servers):
raise HTTPException(400, "Duplicate MCP installation IDs")
atomic_write_extensions_config(path, candidate)
path.chmod(0o600)
return candidate
@router.get("", response_model=mcp.McpConfigResponse)
async def get_configuration(request: Request):
owner = await _owner(request)
return _response(await asyncio.to_thread(_read_personal_config, owner))
async def _write(request: Request, operation: str, body):
owner = await _owner(request)
admin = await is_admin_user(request)
raw = await asyncio.to_thread(_mutate, owner, operation, body, admin=admin)
return _response(raw)
@router.post("/servers", response_model=mcp.McpConfigResponse)
async def create_servers(request: Request, body: mcp.McpConfigUpdateRequest):
return await _write(request, "create", body)
@router.put("/server", response_model=mcp.McpConfigResponse)
async def update_server(request: Request, body: mcp.McpServerConfigUpdateRequest):
return await _write(request, "update", body)
@router.patch("", response_model=mcp.McpConfigResponse)
async def update_state(request: Request, body: mcp.McpServerStateUpdateRequest):
return await _write(request, "state", body)
@router.delete("/servers/{server_name:path}", response_model=mcp.McpConfigResponse)
async def delete_server(request: Request, server_name: str):
return await _write(request, "delete", server_name)

View File

@ -58,3 +58,6 @@
[backend/AGENTS.md](../../../../AGENTS.md)): Long-running MCP work uses a separate durable task runtime rather than keeping remote task IDs or status polling inside the Agent loop. Explicit `task_toolsets` bind raw submit/status/cancel names; only submit remains Agent-visible, and its wrapper persists the remote handle before returning a local ID. `McpTaskService` claims due rows with leases, resolves a protocol-specific `McpTaskDriver`, and writes normalized snapshots back to `mcp_tasks`; expired leases are the restart-recovery mechanism, and a result returned after expiry or after a cancel request must be discarded even when the owner token still matches. The first cancel request fences an in-flight poll lease, while repeats preserve an active cancellation lease so they cannot issue concurrent remote cancels; cancellation backoff starts when the remote attempt finishes, so a slow timeout cannot consume the retry delay. Cancellation, polling, and notification batches isolate per-task exceptions; an unexpected cancellation/poll failure leaves that record's lease to expire, while notification failures release only the affected lease for retry. Input-required and terminal event snapshots are delivered by idempotent Agent runs and marked delivered only after run success; the trusted notification instruction stays outside the input boundary while the serialized remote event is framed as untrusted data. A busy-thread conflict is normalized back to the service boundary so the queued snapshot coalesces to the latest task event. A missing dispatched run becomes a failed delivery attempt, while transient run-store hydration errors stay distinguishable and retry the same lookup. The database is the source of truth; `ThreadState` receives only a bounded current-thread projection, and display names are neutralized at that model-state boundary. The installed process-local submitter is the source of truth for management-tool exposure; hot `mcp_tasks` edits take effect only after restart, and active skills must explicitly declare the list/cancel business tools.
**Task notification failure handling**: MCP notification failures use a consecutive counter separate from the idempotency-key `dispatch_attempt`, capped exponential backoff, latest-event rebuilding before a run launches, and a five-attempt budget before `dead_letter`. A permanently missing/mismatched target thread is dead-lettered immediately instead of being recreated or reclaimed. HTTP and Agent cancellation requests return after the durable cancel fence; the background loop alone owns the potentially slow remote call and retry schedule. The HTTP cancel endpoint rejects requests with 503 when the loop is not running (`mcp_tasks_available` false, e.g. `mcp_tasks.enabled=false` with SQL persistence), so a cancellation is never acknowledged without a worker to perform it. The bounded notification error/count/status join poll and cancellation diagnostics in the task detail API and expanded card.
- Personal MCP configs live under the authenticated user root (`user_config.py`), use literal values, and never enter the deployment tool cache. `user_tools.py` guards owner and current revision before every call. Runtime server names bind owner/name/revision; durable task `driver_data.connection_scope` records personal or deployment ownership at submission, and background calls resolve a personal key only in the persisted task owner's file. Older rows without that field are deployment-owned. When a deployment name matches a personal runtime name, Agent assembly omits the personal tool rather than exposing two tools under one name; an already submitted personal task still uses its recorded personal scope. Keep catalog IDs stable across edits; never fall back by display name.
- Personal definitions without `personal_public_network: true` require current administrator authority at discovery and every tool/durable call. `personal_access.py` uses a host-installed async lookup, never a captured role or cached decision; absent/failed lookup denies privileged connections. The Gateway binds the lookup to fresh account records on its database-owning loop for its lifespan. Deployment connections are unaffected. This is admission control, not termination of operations already admitted before a role change.

View File

@ -36,6 +36,10 @@ def build_server_params(server_name: str, config: McpServerConfig) -> dict[str,
if not config.url:
raise ValueError(f"MCP server '{server_name}' with {transport_type} transport requires 'url' field")
params["url"] = config.url
if (config.model_extra or {}).get("personal_public_network") is True:
from deerflow.mcp.personal_network import personal_httpx_client_factory
params["httpx_client_factory"] = personal_httpx_client_factory
# Add headers if present
if config.headers:
# A statically configured value the transport would refuse gets the

View File

@ -0,0 +1,46 @@
"""Current host authority for personal connections saved with admin privileges."""
from collections.abc import Awaitable, Callable
from langchain_core.tools import ToolException
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
PersonalMcpAdminChecker = Callable[[str], Awaitable[bool]]
_admin_checker: PersonalMcpAdminChecker | None = None
def set_personal_mcp_admin_checker(checker: PersonalMcpAdminChecker | None) -> PersonalMcpAdminChecker | None:
"""Install the host's fresh authority lookup, returning the previous lookup."""
global _admin_checker
previous, _admin_checker = _admin_checker, checker
return previous
def requires_admin(server: McpServerConfig) -> bool:
# Only definitions admitted under the ordinary-user policy carry True.
# Missing markers, including legacy personal files, require authority too.
return (server.model_extra or {}).get("personal_public_network") is not True
async def _is_current_admin(owner: str) -> bool:
checker = _admin_checker
if checker is None:
return False
try:
return await checker(owner) is True
except Exception:
# Authority lookup failures must not reuse a previous admin decision.
return False
async def require_personal_mcp_access(owner: str, server: McpServerConfig) -> None:
if requires_admin(server) and not await _is_current_admin(owner):
raise ToolException("Current administrator access is required for this personal MCP connection; save it again under your current permissions")
async def authorized_personal_config(owner: str, config: ExtensionsConfig) -> ExtensionsConfig:
"""Omit privileged connections before discovery can launch or contact them."""
if not any(server.enabled and requires_admin(server) for server in config.mcp_servers.values()) or await _is_current_admin(owner):
return config
return config.model_copy(update={"mcp_servers": {name: server for name, server in config.mcp_servers.items() if not requires_admin(server)}})

View File

@ -0,0 +1,62 @@
"""Public-network policy for non-operator personal HTTP MCP connections."""
import asyncio
import ipaddress
import httpx
from deerflow.community.url_safety import resolve_host_addresses, validate_public_http_url
def _public_addresses(url: httpx.URL) -> list[str]:
addresses = []
def resolve(host):
candidates = resolve_host_addresses(host)
addresses.extend(str(address) for address in candidates)
return candidates
if validate_public_http_url(str(url), action="connect to", resolver=resolve):
raise ValueError("Personal MCP connections require a public HTTP(S) endpoint")
# Literal IP URLs are validated without calling the resolver.
return list(dict.fromkeys(addresses)) if addresses else [str(ipaddress.ip_address(url.host))]
class _PersonalTransport(httpx.AsyncBaseTransport):
def __init__(self):
self._ssl_context = httpx.create_ssl_context(trust_env=False)
self._transports: dict[tuple[bytes, bytes, int | None], httpx.AsyncHTTPTransport] = {}
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
addresses = await asyncio.to_thread(_public_addresses, request.url)
origin = (request.url.raw_scheme, request.url.raw_host, request.url.port)
# Pin TCP to the vetted IP, but isolate pools by original origin so two
# names sharing an IP cannot reuse a connection authenticated for one.
if origin not in self._transports:
self._transports[origin] = httpx.AsyncHTTPTransport(verify=self._ssl_context, trust_env=False)
transport = self._transports[origin]
for index, address in enumerate(addresses):
pinned = httpx.Request(
request.method,
request.url.copy_with(host=address),
headers=request.headers,
stream=request.stream,
extensions={**request.extensions, "sni_hostname": request.url.raw_host.decode("ascii")},
)
try:
return await transport.handle_async_request(pinned)
except (httpx.ConnectError, httpx.ConnectTimeout):
if index == len(addresses) - 1:
raise
raise AssertionError("Public address validation returned no addresses")
async def aclose(self):
for transport in self._transports.values():
await transport.aclose()
def personal_httpx_client_factory(headers=None, timeout=None, auth=None):
kwargs = {"headers": headers, "auth": auth, "follow_redirects": False, "trust_env": False, "transport": _PersonalTransport()}
if timeout is not None:
kwargs["timeout"] = timeout
return httpx.AsyncClient(**kwargs)

View File

@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio
import logging
import threading
from collections import OrderedDict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from typing import Any, Literal
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.paths import get_paths
@ -17,11 +19,15 @@ from deerflow.mcp.context_headers import build_context_headers_interceptor
from deerflow.mcp.headers import apply_header_overrides
from deerflow.mcp.interceptors import build_mcp_tool_interceptors
from deerflow.mcp.oauth import OAuthTokenManager, build_oauth_tool_interceptor
from deerflow.mcp.personal_access import require_personal_mcp_access
from deerflow.mcp.session_pool import MCPSessionPool, call_pooled_session_tool, get_session_pool
from deerflow.mcp.user_config import PersonalMcpConfigSnapshot, load_user_mcp_config_if_changed
from deerflow.mcp_scope import mcp_session_scope_key
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
# Cached callers retain personal credentials and OAuth state in memory.
_MAX_PERSONAL_CALLERS = 128
@dataclass
@ -64,6 +70,8 @@ class McpTaskToolCaller:
oauth_token_manager: OAuthTokenManager | None = None,
) -> None:
self._extensions_config = extensions_config
self._personal_callers: OrderedDict[str, tuple[PersonalMcpConfigSnapshot, McpTaskToolCaller | None]] = OrderedDict()
self._personal_callers_lock = threading.Lock()
self._oauth_token_manager = oauth_token_manager or OAuthTokenManager.from_extensions_config(extensions_config)
context_headers_interceptor = build_context_headers_interceptor(extensions_config)
# Built once so the two chains keep an identical interceptor order and a
@ -100,14 +108,68 @@ class McpTaskToolCaller:
thread_id: str,
thread_incarnation: str | None = None,
request_scoped_headers: bool = False,
connection_scope: Literal["deployment", "personal"] = "deployment",
) -> Any:
"""Call a raw MCP tool.
``request_scoped_headers`` opts this call into the ``headers_from_context``
interceptor. Only the durable *submit* may set it: submit is awaited
inside the Agent run that carries the secrets, while status and cancel
run after that run ended.
run after that run ended. ``connection_scope`` is captured in the task
binding, so an equal deployment server name cannot change its owner.
"""
if connection_scope == "personal":
caller = await asyncio.to_thread(self._personal_caller_for, user_id, server_name)
await require_personal_mcp_access(user_id, caller._extensions_config.mcp_servers[server_name])
return await caller._call_configured_tool(
server_name=server_name,
tool_name=tool_name,
arguments=arguments,
user_id=user_id,
thread_id=thread_id,
thread_incarnation=thread_incarnation,
request_scoped_headers=request_scoped_headers,
)
if connection_scope != "deployment":
raise ValueError("Invalid MCP task connection scope")
return await self._call_configured_tool(
server_name=server_name,
tool_name=tool_name,
arguments=arguments,
user_id=user_id,
thread_id=thread_id,
thread_incarnation=thread_incarnation,
request_scoped_headers=request_scoped_headers,
)
def _personal_caller_for(self, user_id: str, server_name: str) -> McpTaskToolCaller:
with self._personal_callers_lock:
previous = self._personal_callers.get(user_id)
snapshot = load_user_mcp_config_if_changed(user_id, previous[0] if previous else None)
caller = previous[1] if previous and previous[0] is snapshot else None
self._personal_callers[user_id] = (snapshot, caller)
self._personal_callers.move_to_end(user_id)
if len(self._personal_callers) > _MAX_PERSONAL_CALLERS:
self._personal_callers.popitem(last=False)
server = snapshot.config.mcp_servers.get(server_name)
if server is None or not server.enabled:
raise LookupError("Personal MCP task connection is missing, disabled or changed")
if caller is None:
caller = McpTaskToolCaller(snapshot.config)
self._personal_callers[user_id] = (snapshot, caller)
return caller
async def _call_configured_tool(
self,
*,
server_name: str,
tool_name: str,
arguments: dict[str, Any],
user_id: str,
thread_id: str,
thread_incarnation: str | None,
request_scoped_headers: bool,
) -> Any:
is_background_call = not request_scoped_headers
interceptors = self._interceptors if is_background_call else self._submit_interceptors
server_config = self._extensions_config.get_enabled_mcp_servers().get(server_name)

View File

@ -34,6 +34,7 @@ class McpTaskToolCaller(Protocol):
thread_id: str,
thread_incarnation: str | None = None,
request_scoped_headers: bool = False,
connection_scope: Literal["deployment", "personal"] = "deployment",
) -> Any: ...
@ -86,6 +87,14 @@ def _tool_name(data: dict[str, Any], role: str) -> str:
return value
def _connection_scope(data: dict[str, Any]) -> Literal["deployment", "personal"]:
# Older task rows predate personal connections and are deployment-owned.
scope = data.get("connection_scope", "deployment")
if scope not in ("deployment", "personal"):
raise McpTaskProtocolError("Task driver_data has an invalid connection scope")
return scope
def _first_error_text(call_result: Any) -> str | None:
content = getattr(call_result, "content", None)
if not isinstance(content, (list, tuple)):
@ -165,6 +174,7 @@ class OrdinaryMcpTaskDriver:
# durable-task call that can carry the run's request-scoped
# credentials; status and cancel run after that run ended.
request_scoped_headers=True,
connection_scope=_connection_scope(request.driver_data),
)
payload = _parse(
_SubmitPayload,
@ -187,6 +197,7 @@ class OrdinaryMcpTaskDriver:
user_id=task.user_id,
thread_id=task.thread_id,
thread_incarnation=task.thread_incarnation,
connection_scope=_connection_scope(task.driver_data),
)
payload = _parse(
_StatusPayload,
@ -206,6 +217,7 @@ class OrdinaryMcpTaskDriver:
user_id=task.user_id,
thread_id=task.thread_id,
thread_incarnation=task.thread_incarnation,
connection_scope=_connection_scope(task.driver_data),
)
payload = _parse(
_CancelPayload,

View File

@ -726,6 +726,7 @@ def _make_background_submit_tool(
submit_tool: str,
status_tool: str,
cancel_tool: str,
connection_scope: str,
) -> BaseTool:
background_contract = f"Submitted as durable background task {task_name!r}; returns a DeerFlow task ID immediately and status polling is handled automatically."
@ -755,6 +756,7 @@ def _make_background_submit_tool(
"submit_tool": submit_tool,
"status_tool": status_tool,
"cancel_tool": cancel_tool,
"connection_scope": connection_scope,
},
),
)
@ -780,6 +782,7 @@ def _configure_task_tools_for_server(
server_name: str,
server_config: McpServerConfig,
tool_name_prefix: bool,
connection_scope: str = "deployment",
) -> list[BaseTool]:
"""Hide driver-only tools and replace submit with a durable wrapper."""
if not server_config.task_toolsets:
@ -829,12 +832,13 @@ def _configure_task_tools_for_server(
submit_tool=toolset.submit_tool,
status_tool=toolset.status_tool,
cancel_tool=toolset.cancel_tool,
connection_scope=connection_scope,
)
)
return configured
async def get_mcp_tools(extensions_config: ExtensionsConfig | None = None) -> list[BaseTool]:
async def get_mcp_tools(extensions_config: ExtensionsConfig | None = None, *, personal_user_id: str | None = None) -> list[BaseTool]:
"""Get all tools from enabled MCP servers.
Tools using stdio transport are wrapped with persistent-session logic so
@ -864,7 +868,19 @@ async def get_mcp_tools(extensions_config: ExtensionsConfig | None = None) -> li
# reflected when initializing MCP tools. Callers that need to prove which
# revision produced these tools pass the instance they snapshotted instead.
extensions_config = ExtensionsConfig.from_file()
validate_mcp_task_config_snapshot(extensions_config)
if personal_user_id is None:
validate_mcp_task_config_snapshot(extensions_config)
else:
from deerflow.mcp.personal_access import authorized_personal_config
from deerflow.mcp.tasks.runtime import is_mcp_task_runtime_available
from deerflow.mcp.user_config import load_user_mcp_config
current = await asyncio.to_thread(load_user_mcp_config, personal_user_id)
if current != extensions_config:
raise McpTaskConfigurationError("Personal MCP configuration changed during discovery; retry the run")
extensions_config = current = await authorized_personal_config(personal_user_id, current)
if any(server.task_toolsets for server in current.mcp_servers.values()) and not is_mcp_task_runtime_available():
raise McpTaskConfigurationError("Personal MCP task toolsets require the platform's durable task runtime")
servers_config = build_servers_config(extensions_config)
if not servers_config:
@ -1022,6 +1038,7 @@ async def get_mcp_tools(extensions_config: ExtensionsConfig | None = None) -> li
server_name=source_name,
server_config=server_cfg,
tool_name_prefix=tool_name_prefix,
connection_scope="personal" if personal_user_id is not None else "deployment",
)
wrapped_tools.extend(current_server_tools)

View File

@ -0,0 +1,77 @@
"""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 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")

View File

@ -0,0 +1,61 @@
"""Load only the caller's personal tools; never publish them in the global cache."""
from __future__ import annotations
import asyncio
import functools
import threading
from langchain_core.tools import ToolException
from deerflow.mcp.personal_access import require_personal_mcp_access
from deerflow.mcp.user_config import PersonalMcpConfigSnapshot, load_user_mcp_config, load_user_mcp_config_if_changed
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.mcp_metadata import get_mcp_source
from deerflow.tools.sync import make_sync_tool_wrapper
def _guard(tool, owner: str, server_name: str):
original = tool.coroutine
snapshot: PersonalMcpConfigSnapshot | None = None
snapshot_lock = threading.Lock()
def current_connection():
nonlocal snapshot
with snapshot_lock:
snapshot = load_user_mcp_config_if_changed(owner, snapshot)
server = snapshot.config.mcp_servers.get(server_name)
return server if server is not None and server.enabled else None
@functools.wraps(original)
async def invoke(*args, **kwargs):
if resolve_runtime_user_id(kwargs.get("runtime")) != owner:
raise ToolException("This MCP connection belongs to another user")
server = await asyncio.to_thread(current_connection)
if server is None:
raise ToolException("This personal MCP connection was changed, disabled or removed; start a new run")
await require_personal_mcp_access(owner, server)
return await original(*args, **kwargs)
return tool.model_copy(update={"coroutine": invoke, "func": make_sync_tool_wrapper(invoke, tool.name)})
async def _load(owner: str, config):
from deerflow.mcp.tools import get_mcp_tools
tools = await get_mcp_tools(config, personal_user_id=owner)
return [_guard(tool, owner, source["server_name"]) for tool in tools if (source := get_mcp_source(tool))]
def get_user_mcp_tools():
"""Discover from one literal snapshot, with an ownership check on every call.
Deliberately keep personal discovery out of the deployment's singleton
cache. Stdio session reuse remains owner/thread/revision-scoped.
"""
owner = resolve_runtime_user_id(None)
config = load_user_mcp_config(owner)
if not config.get_enabled_mcp_servers():
return [], config
tools = make_sync_tool_wrapper(_load, "personal MCP discovery")(owner, config)
return tools, config

View File

@ -26,7 +26,7 @@ from deerflow.tools.builtins import (
task_tool,
view_image_tool,
)
from deerflow.tools.mcp_metadata import tag_mcp_tool
from deerflow.tools.mcp_metadata import get_mcp_source, tag_mcp_tool
from deerflow.tools.sync import make_sync_tool_wrapper
logger = logging.getLogger(__name__)
@ -262,6 +262,25 @@ def get_available_tools(
# MCP pays no config-hashing or discovery cost, while one that
# did still checks the existing cache for staleness.
refresh_mcp_cache_if_active()
from deerflow.mcp.user_tools import get_user_mcp_tools
try:
personal_tools, personal_config = get_user_mcp_tools()
except Exception as exc:
# Invalid personal credentials must neither be logged nor
# bypass explicit selection of the platform tools.
logger.warning("Could not load personal MCP tools (%s)", type(exc).__name__)
personal_tools, personal_config = [], ExtensionsConfig()
colliding_names = extensions_config.mcp_servers.keys() & personal_config.mcp_servers.keys()
if colliding_names:
# A deployment name can match a generated personal runtime
# name. Keep only the deployment tool and its routing metadata.
logger.warning("Skipping personal MCP tools with deployment name collisions: %s", sorted(colliding_names))
personal_tools = [tool for tool in personal_tools if (source := get_mcp_source(tool)) and source["server_name"] not in colliding_names]
available_servers = {name: server for name, server in personal_config.mcp_servers.items() if name not in colliding_names}
personal_config = personal_config.model_copy(update={"mcp_servers": available_servers})
mcp_tools = [*mcp_tools, *personal_tools]
extensions_config = extensions_config.model_copy(update={"mcp_servers": {**extensions_config.mcp_servers, **personal_config.mcp_servers}})
if mcp_plugins is not None:
from deerflow.capabilities.runtime import filter_mcp_plugins

View File

@ -172,6 +172,30 @@ def test_lifespan_sweeps_upload_staging_files_on_startup():
stop_channel_service.assert_awaited_once()
def test_personal_mcp_authority_spans_runtime_startup_and_shutdown():
from deerflow.mcp import personal_access
events = []
previous = personal_access._admin_checker
@asynccontextmanager
async def checked_runtime(_app, _config):
checker = personal_access._admin_checker
assert checker is not None and checker is not previous
events.append("start")
try:
yield
finally:
assert personal_access._admin_checker is checker
events.append("stop")
with patch(f"{__name__}._noop_langgraph_runtime", checked_runtime):
asyncio.run(_run_lifespan_with_upload_staging_cleanup())
assert events == ["start", "stop"]
assert personal_access._admin_checker is previous
async def _run_lifespan_with_mcp_task_config_snapshot() -> None:
from app.gateway.app import lifespan
from deerflow.config.extensions_config import ExtensionsConfig

View File

@ -1,3 +1,4 @@
from dataclasses import replace
from types import SimpleNamespace
import pytest
@ -80,10 +81,41 @@ async def test_submit_uses_structured_content_and_keeps_remote_id_out_of_driver_
# Submit is the one durable-task call awaited inside the Agent run,
# so it is the only one that may carry request-scoped credentials.
"request_scoped_headers": True,
"connection_scope": "deployment",
}
]
@pytest.mark.asyncio
async def test_personal_connection_scope_survives_submit_status_and_cancel() -> None:
caller = FakeCaller(
_result({"task_id": "remote-1", "status": "running"}),
_result({"task_id": "remote-1", "status": "completed"}),
_result({"task_id": "remote-1", "status": "cancelled"}),
)
driver = OrdinaryMcpTaskDriver(caller)
request = _request()
request = replace(request, driver_data={**request.driver_data, "connection_scope": "personal"})
submission = await driver.submit(request)
reference = replace(_reference(), driver_data=submission.driver_data)
await driver.get_status(reference)
await driver.cancel(reference)
assert submission.driver_data["connection_scope"] == "personal"
assert [call["connection_scope"] for call in caller.calls] == ["personal"] * 3
@pytest.mark.asyncio
async def test_unknown_task_connection_scope_fails_closed() -> None:
driver = OrdinaryMcpTaskDriver(FakeCaller())
reference = _reference()
reference = replace(reference, driver_data={**reference.driver_data, "connection_scope": "unknown"})
with pytest.raises(McpTaskProtocolError, match="connection scope"):
await driver.get_status(reference)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"structured_content,match",

View File

@ -124,7 +124,8 @@ def test_configured_task_toolsets_fail_when_a_raw_tool_is_missing() -> None:
@pytest.mark.asyncio
async def test_submit_wrapper_persists_before_returning_only_the_local_handle() -> None:
@pytest.mark.parametrize("connection_scope", ["deployment", "personal"])
async def test_submit_wrapper_persists_before_returning_only_the_local_handle(connection_scope: str) -> None:
submitter = FakeSubmitter()
set_mcp_task_submitter(submitter)
try:
@ -137,6 +138,7 @@ async def test_submit_wrapper_persists_before_returning_only_the_local_handle()
server_name="reports",
server_config=_server_config(),
tool_name_prefix=False,
connection_scope=connection_scope,
)
submit_tool = configured[0]
runtime = SimpleNamespace(
@ -165,6 +167,7 @@ async def test_submit_wrapper_persists_before_returning_only_the_local_handle()
"submit_tool": "submit_report",
"status_tool": "get_report_status",
"cancel_tool": "cancel_report",
"connection_scope": connection_scope,
}
finally:
set_mcp_task_submitter(None)

View File

@ -0,0 +1,545 @@
"""Personal MCP persistence, HTTP ownership and real MCP credential routing."""
import asyncio
import ipaddress
import json
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, 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}"}}}},
)
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"
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.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
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(
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"

View File

@ -0,0 +1,172 @@
"""Privilege revocation uses current accounts even with stale tools and caches."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from langchain_core.tools import StructuredTool, ToolException
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import personal_mcp_access as gateway_access
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.models import User
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, atomic_write_extensions_config
from deerflow.config.paths import Paths
from deerflow.mcp import personal_access
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
from deerflow.mcp.user_config import load_user_mcp_config, user_mcp_config_path
from deerflow.mcp.user_tools import _guard, _load
from deerflow.persistence.user.model import UserRow
from deerflow.runtime.user_context import reset_current_user, set_current_user
@pytest_asyncio.fixture
async def accounts(tmp_path, monkeypatch):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/users.db")
async with engine.begin() as connection:
await connection.run_sync(UserRow.__table__.create)
sessions = async_sessionmaker(engine, expire_on_commit=False)
repository = SQLiteUserRepository(sessions)
user = await repository.create_user(User(email="admin@example.com", system_role="admin"))
monkeypatch.setattr(gateway_access, "get_local_provider", lambda: LocalAuthProvider(repository))
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(personal_access, "_admin_checker", None)
try:
yield repository, user, sessions
finally:
await engine.dispose()
def privileged_definition(kind):
if kind == "stdio":
return {"type": "stdio", "command": "npx", "args": ["example-package"], "personal_public_network": False}
definition = {"type": "sse" if kind == "sse" else "http", "url": "http://127.0.0.1/mcp", "personal_public_network": False}
if kind == "legacy":
del definition["personal_public_network"]
if kind == "oauth":
definition["oauth"] = {"token_url": "http://127.0.0.1/token", "client_id": "test-client"}
return definition
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["http", "sse", "stdio", "legacy", "oauth"])
@pytest.mark.parametrize("revocation", ["demote", "delete", "lookup_failure"])
async def test_revocation_blocks_discovery_existing_tools_and_cached_durable_calls(accounts, monkeypatch, kind, revocation):
repository, user, sessions = accounts
owner = str(user.id)
path = user_mcp_config_path(owner)
atomic_write_extensions_config(path, {"mcpServers": {"privileged": privileged_definition(kind), "public": {"type": "http", "url": "https://example.com/mcp", "personal_public_network": True}}})
before = path.read_bytes()
config = load_user_mcp_config(owner)
privileged = next(name for name, server in config.mcp_servers.items() if personal_access.requires_admin(server))
public = next(name for name in config.mcp_servers if name != privileged)
observed = []
async def execute():
observed.append("executed")
return "ok"
class Client:
def __init__(self, connections, **kwargs):
observed.extend(connections)
async def get_tools(self, server_name):
return [StructuredTool.from_function(coroutine=execute, name=server_name + "_probe", description="Probe")]
monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", Client)
oauth = AsyncMock(return_value={})
monkeypatch.setattr("deerflow.mcp.tools.get_initial_oauth_headers", oauth)
invoke = AsyncMock(return_value="ok")
monkeypatch.setattr(McpTaskToolCaller, "_call_configured_tool", invoke)
caller = McpTaskToolCaller(ExtensionsConfig())
guarded = _guard(StructuredTool.from_function(coroutine=execute, name="probe", description="Probe"), owner, privileged)
public_tool = _guard(StructuredTool.from_function(coroutine=execute, name="public", description="Public"), owner, public)
token = set_current_user(SimpleNamespace(id=owner, system_role="admin"))
try:
async with gateway_access.personal_mcp_authority():
assert len(await _load(owner, config)) == 2
assert await guarded.ainvoke({}) == "ok"
call = dict(server_name=privileged, tool_name="submit", arguments={}, user_id=owner, thread_id="thread", connection_scope="personal")
assert await caller.call_tool(**call) == "ok"
cached_caller = caller._personal_callers[owner][1]
observed.clear()
invoke.reset_mock()
oauth.reset_mock()
if revocation == "demote":
user.system_role = "user"
await repository.update_user(user)
elif revocation == "delete":
async with sessions() as session:
await session.execute(delete(UserRow).where(UserRow.id == owner))
await session.commit()
else:
monkeypatch.setattr(repository, "get_user_by_id", AsyncMock(side_effect=RuntimeError("database unavailable")))
tools = await _load(owner, config)
assert len(tools) == 1
assert observed == [public]
assert set(oauth.call_args.args[0].mcp_servers) == {public}
with pytest.raises(ToolException, match="Current administrator"):
await guarded.ainvoke({})
for operation in ("submit", "status", "cancel"):
with pytest.raises(ToolException, match="Current administrator"):
await caller.call_tool(**(call | {"tool_name": operation, "request_scoped_headers": operation == "submit"}))
assert caller._personal_callers[owner][1] is cached_caller
invoke.assert_not_awaited()
assert path.read_bytes() == before
assert await public_tool.ainvoke({}) == "ok"
# Deployment calls retain their existing policy and never query
# personal ownership or the owner's current role.
assert await caller.call_tool(**(call | {"connection_scope": "deployment"})) == "ok"
finally:
reset_current_user(token)
@pytest.mark.asyncio
async def test_authority_lookup_stays_on_gateway_loop_during_sync_discovery(accounts, monkeypatch):
repository, user, _ = accounts
owner = str(user.id)
loop = asyncio.get_running_loop()
original = repository.get_user_by_id
reads = []
async def get_user(user_id):
reads.append(asyncio.get_running_loop())
return await original(user_id)
monkeypatch.setattr(repository, "get_user_by_id", get_user)
server = McpServerConfig.model_validate(privileged_definition("http"))
async with gateway_access.personal_mcp_authority():
await asyncio.wait_for(asyncio.to_thread(lambda: asyncio.run(personal_access.require_personal_mcp_access(owner, server))), timeout=5)
assert reads == [loop]
assert personal_access._admin_checker is None
with pytest.raises(ToolException, match="Current administrator"):
await personal_access.require_personal_mcp_access(owner, server)
@pytest.mark.asyncio
@pytest.mark.parametrize("production", [False, True])
async def test_auth_disabled_authority_only_grants_the_default_local_user(accounts, monkeypatch, production):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("DEER_FLOW_ENV", "production" if production else "development")
monkeypatch.delenv("ENVIRONMENT", raising=False)
async with gateway_access.personal_mcp_authority():
assert await personal_access._is_current_admin("default") is (not production)
assert await personal_access._is_current_admin("missing-user") is False
@pytest.mark.asyncio
async def test_authority_registration_is_restored_after_startup_failure(monkeypatch):
previous = AsyncMock(return_value=False)
monkeypatch.setattr(personal_access, "_admin_checker", previous)
with pytest.raises(RuntimeError, match="startup failed"):
async with gateway_access.personal_mcp_authority():
assert personal_access._admin_checker is not previous
raise RuntimeError("startup failed")
assert personal_access._admin_checker is previous

View File

@ -0,0 +1,154 @@
"""Exercise personal MCP policy through the real HTTP transport and TLS boundary."""
import ipaddress
import socket
import httpcore
import pytest
from httpcore._backends.anyio import AnyIOBackend
from deerflow.mcp.personal_network import personal_httpx_client_factory
class RecordingStream(httpcore.AsyncNetworkStream):
def __init__(self):
self.writes = []
self.tls_hosts = []
self.closed = False
async def read(self, max_bytes, timeout=None):
return b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"
async def write(self, buffer, timeout=None):
self.writes.append(buffer)
async def aclose(self):
self.closed = True
async def start_tls(self, ssl_context, server_hostname=None, timeout=None):
assert ssl_context.check_hostname
self.tls_hosts.append(server_hostname)
return self
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ["http", "https"])
async def test_personal_transport_pins_dns_and_preserves_authority(monkeypatch, scheme):
lookups = []
connections = []
streams = []
real_resolve = socket.getaddrinfo
def resolve(host, port, *args, **kwargs):
if host == "mcp.example":
address = "8.8.8.8" if not lookups else "127.0.0.1"
lookups.append(address)
return real_resolve(address, port, *args, **kwargs)
return real_resolve(host, port, *args, **kwargs)
async def connect(self, host, port, **kwargs):
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
connections.append((host, addresses[0][4][0]))
stream = RecordingStream()
streams.append(stream)
return stream
monkeypatch.setattr(socket, "getaddrinfo", resolve)
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory(headers={"Authorization": "Bearer test"}) as client:
response = await client.post(f"{scheme}://mcp.example:8443/mcp?session=1", content=b"payload")
assert response.text == "ok"
assert str(response.request.url) == f"{scheme}://mcp.example:8443/mcp?session=1"
assert connections == [("8.8.8.8", "8.8.8.8")]
assert lookups == ["8.8.8.8"]
wire = b"".join(streams[0].writes)
assert b"POST /mcp?session=1 HTTP/1.1" in wire
assert b"Host: mcp.example:8443" in wire
assert b"Authorization: Bearer test" in wire
assert b"payload" in wire
assert streams[0].tls_hosts == (["mcp.example"] if scheme == "https" else [])
with pytest.raises(ValueError, match="public HTTP"):
await client.get(f"{scheme}://mcp.example:8443/mcp")
assert len(connections) == 1
assert all(stream.closed for stream in streams)
@pytest.mark.asyncio
async def test_personal_transport_does_not_share_tls_across_hostnames(monkeypatch):
streams = []
real_resolve = socket.getaddrinfo
monkeypatch.setattr(socket, "getaddrinfo", lambda host, port, *args, **kwargs: real_resolve("8.8.8.8", port, *args, **kwargs))
async def connect(self, host, port, **kwargs):
stream = RecordingStream()
streams.append(stream)
return stream
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory() as client:
for host in ("first.example", "second.example"):
assert (await client.get(f"https://{host}/mcp")).status_code == 200
assert [stream.tls_hosts for stream in streams] == [["first.example"], ["second.example"]]
assert all(stream.closed for stream in streams)
@pytest.mark.asyncio
@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1"])
async def test_personal_transport_blocks_private_addresses_before_connect(monkeypatch, address):
real_resolve = socket.getaddrinfo
monkeypatch.setattr(socket, "getaddrinfo", lambda host, port, *args, **kwargs: real_resolve(address, port, *args, **kwargs))
async def connect(*args, **kwargs):
pytest.fail("Private address reached TCP connect")
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory() as client:
with pytest.raises(ValueError, match="public HTTP"):
await client.get("https://mcp.example/mcp")
@pytest.mark.asyncio
@pytest.mark.parametrize("addresses", [[], ["8.8.8.8", "127.0.0.1"], ["2606:4700:4700::1111", "::1"]])
async def test_personal_transport_rejects_empty_or_mixed_dns_answers(monkeypatch, addresses):
monkeypatch.setattr("deerflow.mcp.personal_network.resolve_host_addresses", lambda host: [ipaddress.ip_address(address) for address in addresses])
async def connect(*args, **kwargs):
pytest.fail("Rejected DNS answers reached TCP connect")
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory() as client:
with pytest.raises(ValueError, match="public HTTP"):
await client.get("https://mcp.example/mcp")
@pytest.mark.asyncio
async def test_personal_transport_tries_only_vetted_addresses(monkeypatch):
addresses = ["2606:4700:4700::1111", "8.8.8.8"]
monkeypatch.setattr("deerflow.mcp.personal_network.resolve_host_addresses", lambda host: [ipaddress.ip_address(address) for address in addresses])
attempts = []
async def connect(self, host, port, **kwargs):
attempts.append(host)
if host == addresses[0]:
raise httpcore.ConnectError("IPv6 unavailable")
return RecordingStream()
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory() as client:
assert (await client.get("https://mcp.example/mcp")).status_code == 200
assert attempts == addresses
@pytest.mark.asyncio
@pytest.mark.parametrize("address", ["8.8.8.8", "[2606:4700:4700::1111]"])
async def test_personal_transport_accepts_public_literal_ips(monkeypatch, address):
attempts = []
async def connect(self, host, port, **kwargs):
attempts.append(host)
return RecordingStream()
monkeypatch.setattr(AnyIOBackend, "connect_tcp", connect)
async with personal_httpx_client_factory() as client:
assert (await client.get(f"http://{address}/mcp")).status_code == 200
assert attempts == [address.strip("[]")]

View File

@ -20,7 +20,8 @@ deferred to a separate proposal and PR.
| Validated manifest schema | `deerflow.capabilities.catalog.PluginManifest` |
| Installation/status adapters | `backend/app/gateway/capabilities.py` |
| Catalog and safe discovery HTTP API | `backend/app/gateway/routers/capabilities.py` |
| MCP settings, secrets, enable/delete, cache reload | Existing `/api/mcp/config` services and `extensions_config.json` |
| Personal MCP settings, secrets, enable/delete | `/api/mcp/personal/config` and `.deer-flow/users/<user_id>/integrations/mcp.json` |
| Deployment MCP settings and cache reload | Administrator-only `/api/mcp/config` and `extensions_config.json` |
| Lark installation and personal account authorization | Existing `/api/integrations/lark` services |
| Skill archives, enable state, user storage | Existing `/api/skills` services |
| Agent selection | Agent config `mcp_plugins` and existing `skills` |
@ -35,7 +36,7 @@ loader is separate from this user-facing directory.
Three entries ship working API clients with the harness, using the `business`
configuration adapter and the existing stdio MCP runtime. No separate service,
package download, or Dify runtime is needed. Configure them under **Capability
Center → Plugins** as an administrator:
Center → Plugins** using your own account:
| Plugin | Configuration | Tools |
| --- | --- | --- |
@ -51,10 +52,15 @@ DeerFlow's incoming IM channels. Existing manually configured CLI connections
are not rewritten when the catalog entry changes.
Configuration saves credentials without sending a message or creating a CRM
record. These deployment credentials are shared by runs allowed to use the
configured MCP server; they are not personal OAuth connections. Credentials
remain in the existing MCP `env` configuration and its masked admin editor.
Edit, toggle and delete the configured entry using the existing MCP controls.
record. Connections created in the web interface are personal: credentials,
enabled state and edits belong to the signed-in user, including administrators.
They are stored under `.deer-flow/users/<user_id>/integrations/mcp.json` and
remain masked in the editor. Existing deployment connections in
`extensions_config.json` stay shared and are not copied to any personal account.
The page separates **Platform provided** (deployment setup guides and shared MCP
entries) from **My plugins** (personal connections, configuration templates and
Lark account authorization). Existing search and category filters apply to both.
Edit, toggle and delete personal entries using the existing MCP controls.
An Agent selects these connections through **Plugins and skills**, just like
other MCP servers. New tool selection applies on the next run.
@ -118,7 +124,7 @@ only link to setup instructions; listing them never claims they are installed.
The catalog version describes this manifest, not a remotely detected server
version. A catalog listing does not install, enable, or authorize anything.
The first MCP form supports HTTP endpoints and a deployment Authorization header.
The MCP form saves a personal HTTP endpoint and Authorization header.
The advanced JSON editor retains stdio, SSE, OAuth token configuration, and
per-user credential mappings supported by the existing MCP runtime. The presence
of `oauth` in a manifest is not a new interactive OAuth implementation. Lark
@ -134,8 +140,11 @@ service's authorization and validation; never add a provider branch to the galle
- `GET /api/capabilities/catalog`: validated bundled manifests.
- `GET /api/capabilities/installations/{adapter}`: safe installation projections
for `mcp`, `business`, `lark`, and `skills`; separate requests isolate integration failures.
- `POST /api/capabilities/installations`: administrator installation dispatch.
Body: `plugin_id`, `name`, and adapter `configuration`. MCP configuration uses
- `POST /api/capabilities/installations`: installation dispatch with `plugin_id`,
`name`, adapter `configuration`, and optional `scope` (default `deployment`).
Deployment scope requires an administrator; authenticated users may choose
`scope: "user"` for MCP and bundled business connections. Other adapters
still require an administrator. MCP configuration uses
the existing server definition schema. HTTP/SSE connections require a valid
HTTP(S) URL without embedded credentials; stdio connections require a command.
Invalid transport configuration returns 422 before saving. Duplicate server
@ -204,3 +213,42 @@ Agent persistence, delegated selection, and a real local stdio MCP invocation.
Browser tests cover catalog installation, Agent selection save/reopen, existing
icon editing, filtering, and desktop/mobile settings layouts. Browser fixture
screenshots show sample integrations; they are not production default settings.
## Personal and deployment MCP configuration
`config.yaml` tools and existing `extensions_config.json` MCP servers remain
platform-provided capabilities. The administrator-only `/api/mcp/config` API
continues to manage deployment MCP configuration.
The web editor uses `/api/mcp/personal/config`: GET reads only the authenticated
user's definitions; POST `/servers`, PUT `/server`, PATCH and DELETE
`/servers/{name}` manage that same user's file. No caller-supplied user ID selects
the owner. Catalog installation accepts `scope: "user"`; catalog discovery
accepts `scope=user` or `scope=all` (deployment plus the caller's connections).
The default API scope remains deployment for existing integrations.
Personal files use the existing atomic file writer and cross-process lock. Keep
`DEER_FLOW_HOME` on persistent storage; multiple Gateway workers must share it.
Personal values are literal and cannot read deployment environment variables.
A missing personal connection never falls back to a same-named shared one.
Personal tools are discovered per run, outside the process-wide platform cache.
Calls check ownership and the current stored revision. Credential edits, disable
and delete invalidate already assembled personal tools; restart the run after
an edit. Durable tasks bind the same owner and connection revision, including
after recovery; changing that connection blocks its pending remote calls until
the original configuration is restored.
Allowing personal configuration does not grant host execution privileges:
ordinary users may configure public HTTP/SSE endpoints and the fixed bundled
business launchers. Arbitrary stdio commands, internal endpoints and OAuth token
endpoints still require an administrator. Public endpoints are checked again on
HTTP requests, with redirects and environment proxies disabled.
Connections saved with administrator privileges require the owner's current
administrator role during discovery and before every tool call, including
durable submit, poll and cancel calls. Demotion, account deletion or an authority
lookup failure blocks those connections even if tools or callers are cached.
The owner can save a compatible connection again under ordinary-user policy.
Gateway workers query current account records; standalone hosts must install
an authority lookup to use privileged personal definitions. Already admitted
operations are not forcibly terminated by a subsequent role change.

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@ -39,13 +39,14 @@ against raw artifact paths before encoding.
pages; `resolveThreadContext` must neither
enqueue account writes nor create a fallback thread override that masks a
later server preference. The
Capability Center > Plugins MCP switch calls the targeted `PATCH /api/mcp/config`
Capability Center > My plugins MCP switch calls the targeted `PATCH /api/mcp/personal/config`
mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates
`["mcpConfig"]` only after success.
Server management uses targeted `POST /api/mcp/config/servers`,
`PUT /api/mcp/config/server`, and bodyless
`DELETE /api/mcp/config/servers/{server_name}` mutations. Delete names are
Personal server management uses targeted `POST /api/mcp/personal/config/servers`,
`PUT /api/mcp/personal/config/server`, and bodyless
`DELETE /api/mcp/personal/config/servers/{server_name}` mutations. The deployment
`/api/mcp/config` endpoints remain operator-only and are not used by this editor. Delete names are
percent-encoded, including legacy empty and slash-containing names; every
successful mutation invalidates `["mcpConfig"]` only after the response.
Current-chat MCP background tasks use `core/background-tasks`: the header
@ -196,3 +197,5 @@ are sent on save. Custom-agent chat derives its initial selection from the saved
binding; explicit page-local overrides survive new-thread route replacement and
reset on conversation changes. Gateway supplies defaults for clients without a
selector; frontend visibility must not become a runtime enforcement boundary.
MCP editor requests use the personal configuration API. Query keys for personal MCP config and capability installations include the authenticated user ID; remount the editor when that ID changes. Deployment installations remain read-only under Platform provided, alongside deployment setup guides. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.

View File

@ -1,6 +1,6 @@
"use client";
import { type ReactNode } from "react";
import { type ReactNode, useId } from "react";
import { useI18n } from "@/core/i18n/hooks";
@ -77,6 +77,7 @@ export function PluginDirectory({
}) {
const { t } = useI18n();
const copy = t.capabilities.directory;
const groupId = useId();
const visible = entries.filter(
(entry) =>
(!installedOnly || entry.installed) &&
@ -98,12 +99,9 @@ export function PluginDirectory({
const items = visible.filter((entry) => entry.category === key);
if (!items.length) return null;
return (
<section key={key} aria-labelledby={`plugin-group-${key}`}>
<section key={key} aria-labelledby={`${groupId}-${key}`}>
<div className="flex items-baseline gap-3 border-b pb-3">
<h2
id={`plugin-group-${key}`}
className="text-base font-semibold"
>
<h2 id={`${groupId}-${key}`} className="text-base font-semibold">
{copy.categories[key]}
</h2>
<span className="text-muted-foreground/70 text-xs">

View File

@ -76,7 +76,7 @@ export function PluginGallery({ query }: { query: string }) {
const copy = t.capabilities.directory;
const labels = capabilityCopy(locale);
const { user } = useAuth();
const canManage = user?.system_role === "admin" && !isStaticWebsiteOnly();
const canManage = !!user && !isStaticWebsiteOnly();
const directory = useCapabilityCatalog();
const definitions = directory.data ?? [];
const adapterNames = [
@ -86,7 +86,11 @@ export function PluginGallery({ query }: { query: string }) {
.filter((name) => name !== "guide"),
),
];
const states = useQueries({ queries: adapterNames.map(installationQuery) });
const states = useQueries({
queries: adapterNames.map((adapter) =>
installationQuery(adapter, user?.id),
),
});
const client = useQueryClient();
const [filter, setFilter] = useState("all");
const [category, setCategory] = useState<PluginCategory | "all">("all");
@ -113,7 +117,9 @@ export function PluginGallery({ query }: { query: string }) {
.map((item) => [item.id, item]),
).values(),
];
const catalog: PluginDirectoryEntry[] = definitions
const platformCatalog: PluginDirectoryEntry[] = [];
const personalCatalog: PluginDirectoryEntry[] = [];
definitions
.filter(
(plugin) =>
canManage ||
@ -121,12 +127,16 @@ export function PluginGallery({ query }: { query: string }) {
(item) => item.adapter === "mcp" && item.plugin_id === plugin.id,
),
)
.map((plugin) => {
.forEach((plugin) => {
const isPlatform = plugin.adapter === "guide";
const status = installations.find(
(item) => item.plugin_id === plugin.id && item.installed,
(item) =>
item.plugin_id === plugin.id &&
item.installed &&
(isPlatform ? item.scope !== "user" : item.scope === "user"),
);
const unavailable = states[adapterNames.indexOf(plugin.adapter)]?.isError;
return {
(isPlatform ? platformCatalog : personalCatalog).push({
id: plugin.id,
category: plugin.category,
installed: !!status,
@ -163,50 +173,49 @@ export function PluginGallery({ query }: { query: string }) {
</Button>
</PluginRow>
),
};
});
// Non-admins see safe installation projections, not the administrator's raw config editor.
if (!canManage)
for (const item of installations.filter((item) => item.adapter === "mcp")) {
const manifest = definitions.find(
(plugin) => plugin.id === item.plugin_id,
);
catalog.push({
id: item.id,
category: manifest?.category ?? "custom",
search: `${item.name} ${item.description}`,
installed: true,
node: (
<PluginRow
name={item.name}
description={item.description}
icon={
<PluginIcon
name={item.name}
icon={item.icon}
asset={manifest?.icon}
capabilityId={manifest?.id}
/>
}
label={
item.selectable === false
? labels.unavailable
: item.enabled
? t.capabilities.enabled
: t.capabilities.disabled
}
>
<span className="text-muted-foreground text-xs">
{item.auth_status === "required"
? labels.required
: item.auth_status === "configured"
? labels.configured
: labels.unknown}
</span>
</PluginRow>
),
});
}
});
// Deployment entries remain read-only; the editor manages personal connections.
for (const item of installations.filter(
(item) => item.adapter === "mcp" && item.scope !== "user",
)) {
const manifest = definitions.find((plugin) => plugin.id === item.plugin_id);
platformCatalog.push({
id: item.id,
category: manifest?.category ?? "custom",
search: `${item.name} ${item.description}`,
installed: true,
node: (
<PluginRow
name={item.name}
description={item.description}
icon={
<PluginIcon
name={item.name}
icon={item.icon}
asset={manifest?.icon}
capabilityId={manifest?.id}
/>
}
label={
item.selectable === false
? labels.unavailable
: item.enabled
? t.capabilities.enabled
: t.capabilities.disabled
}
>
<span className="text-muted-foreground text-xs">
{item.auth_status === "required"
? labels.required
: item.auth_status === "configured"
? labels.configured
: labels.unknown}
</span>
</PluginRow>
),
});
}
const toolbar = (
<Tabs value={filter} onValueChange={setFilter}>
<TabsList>
@ -250,26 +259,69 @@ export function PluginGallery({ query }: { query: string }) {
</div>
{directory.isError && <p role="alert">{labels.catalogError}</p>}
{directory.isLoading && <p role="status">{t.common.loading}</p>}
{canManage ? (
<MCPPluginManager
{toolbar}
<section aria-labelledby="platform-plugins-heading" className="space-y-5">
<div className="space-y-1">
<h2 id="platform-plugins-heading" className="text-xl font-semibold">
{labels.platformTitle}
</h2>
<p className="text-muted-foreground text-sm">{labels.platformHint}</p>
</div>
<PluginDirectory
query={query}
toolbar={toolbar}
category={category}
installedOnly={filter === "installed"}
catalog={catalog}
definitions={definitions}
entries={platformCatalog}
/>
) : (
<>
{toolbar}
<PluginDirectory
</section>
<section
aria-labelledby="personal-plugins-heading"
className="space-y-5 border-t pt-8"
>
{canManage ? (
<MCPPluginManager
key={user?.id}
query={query}
toolbar={
<div className="space-y-1">
<h2
id="personal-plugins-heading"
className="text-xl font-semibold"
>
{labels.personalTitle}
</h2>
<p className="text-muted-foreground text-sm">
{labels.personalHint}
</p>
</div>
}
category={category}
installedOnly={filter === "installed"}
entries={catalog}
catalog={personalCatalog}
definitions={definitions}
/>
</>
)}
) : (
<>
<div className="space-y-1">
<h2
id="personal-plugins-heading"
className="text-xl font-semibold"
>
{labels.personalTitle}
</h2>
<p className="text-muted-foreground text-sm">
{labels.personalHint}
</p>
</div>
<PluginDirectory
query={query}
category={category}
installedOnly={filter === "installed"}
entries={personalCatalog}
/>
</>
)}
</section>
<Dialog open={!!selected} onOpenChange={(value) => !value && close()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
{selected && (
@ -287,10 +339,14 @@ export function PluginGallery({ query }: { query: string }) {
</DialogHeader>
{Settings ? (
<Settings
key={selected.id}
key={`${user?.id}:${selected.id}`}
plugin={selected}
onSaved={close}
canManage={canManage}
canManage={
canManage &&
(selected.adapter !== "lark" ||
user?.system_role === "admin")
}
/>
) : (
<p className="text-muted-foreground text-sm leading-6">

View File

@ -1,4 +1,8 @@
const en = {
platformTitle: "Platform provided",
platformHint: "Shared capabilities managed through deployment configuration.",
personalTitle: "My plugins",
personalHint: "Your connections and settings are only used by your account.",
install: "Configure plugin",
name: "Connection name",
url: "Server URL",
@ -8,7 +12,7 @@ const en = {
webhook_key: "Robot webhook key (the key parameter in its URL)",
save: "Save configuration",
accountHint:
"These are deployment credentials managed by the administrator. Personal authorization, when supported, uses the integration's account flow.",
"This configuration is saved to your account and is only used by your runs.",
saved: "Configuration saved",
unknown: "Not checked",
required: "Account required",
@ -31,6 +35,10 @@ const en = {
invalidUrl: "Enter an HTTP or HTTPS server URL.",
};
const zh: typeof en = {
platformTitle: "平台提供",
platformHint: "由平台统一配置,供所有用户使用。",
personalTitle: "我的插件",
personalHint: "连接和配置仅属于你的账号,其他用户无法使用。",
install: "配置插件",
name: "连接名称",
url: "服务地址",
@ -39,8 +47,7 @@ const zh: typeof en = {
sign_secret: "机器人加签密钥",
webhook_key: "机器人 Webhook 密钥(地址中的 key 参数)",
save: "保存配置",
accountHint:
"这里配置的是由管理员管理的部署凭据。支持个人授权的集成,通过其账号流程连接。",
accountHint: "这里的配置仅保存到你的账号,其他用户无法查看或使用。",
saved: "配置已保存",
unknown: "未检测",
required: "需要连接账号",

View File

@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetch } from "@/core/api/fetcher";
import { useAuth } from "@/core/auth/AuthProvider";
import { getBackendBaseURL } from "@/core/config";
import type { InstallationList, PluginManifest } from "./types";
@ -30,7 +31,8 @@ export function useCapabilityCatalog() {
});
}
export function useCapabilityInstallations(adapter: string) {
return useQuery(installationQuery(adapter));
const { user } = useAuth();
return useQuery(installationQuery(adapter, user?.id));
}
export function useInstallCapability() {
const client = useQueryClient();
@ -43,7 +45,7 @@ export function useInstallCapability() {
request<InstallationList>("installations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify({ ...body, scope: "user" }),
}),
onSuccess: async () => {
await Promise.all([
@ -55,10 +57,12 @@ export function useInstallCapability() {
});
}
export function installationQuery(adapter: string) {
export function installationQuery(adapter: string, userId?: string) {
return {
queryKey: ["capabilities", "installations", adapter],
queryKey: ["capabilities", "installations", adapter, userId],
queryFn: () =>
request<InstallationList>(`installations/${encodeURIComponent(adapter)}`),
request<InstallationList>(
`installations/${encodeURIComponent(adapter)}?scope=all`,
),
};
}

View File

@ -3,6 +3,10 @@ import { getBackendBaseURL } from "@/core/config";
import type { MCPConfig, MCPServerConfig } from "./types";
export type MCPScope = "deployment" | "user";
const configPath = (scope: MCPScope) =>
scope === "user" ? "/api/mcp/personal/config" : "/api/mcp/config";
export class MCPConfigRequestError extends Error {
readonly status: number;
constructor(status: number, message: string) {
@ -25,8 +29,8 @@ async function readErrorDetail(
return typeof error.detail === "string" ? error.detail : fallback;
}
export async function loadMCPConfig() {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`);
export async function loadMCPConfig(scope: MCPScope = "deployment") {
const response = await fetch(`${getBackendBaseURL()}${configPath(scope)}`);
if (!response.ok) {
throw new MCPConfigRequestError(
response.status,
@ -76,27 +80,37 @@ async function mutateMCPServerConfig(
return response.json() as Promise<MCPConfig>;
}
export function createMCPServers(servers: Record<string, MCPServerConfig>) {
export function createMCPServers(
servers: Record<string, MCPServerConfig>,
scope: MCPScope = "deployment",
) {
return mutateMCPServerConfig(
"/api/mcp/config/servers",
`${configPath(scope)}/servers`,
"POST",
{ mcp_servers: servers },
"Failed to add MCP servers",
);
}
export function updateMCPServer(serverName: string, server: MCPServerConfig) {
export function updateMCPServer(
serverName: string,
server: MCPServerConfig,
scope: MCPScope = "deployment",
) {
return mutateMCPServerConfig(
"/api/mcp/config/server",
`${configPath(scope)}/server`,
"PUT",
{ server_name: serverName, server },
"Failed to update MCP server",
);
}
export function deleteMCPServer(serverName: string) {
export function deleteMCPServer(
serverName: string,
scope: MCPScope = "deployment",
) {
return mutateMCPServerConfig(
`/api/mcp/config/servers/${encodeURIComponent(serverName)}`,
`${configPath(scope)}/servers/${encodeURIComponent(serverName)}`,
"DELETE",
undefined,
"Failed to delete MCP server",
@ -106,8 +120,9 @@ export function deleteMCPServer(serverName: string) {
export async function updateMCPServerState(
serverName: string,
enabled: boolean,
scope: MCPScope = "deployment",
) {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
const response = await fetch(`${getBackendBaseURL()}${configPath(scope)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",

View File

@ -6,6 +6,8 @@ import {
} from "@tanstack/react-query";
import { toast } from "sonner";
import { useAuth } from "@/core/auth/AuthProvider";
import {
createMCPServers,
deleteMCPServer,
@ -14,12 +16,15 @@ import {
updateMCPServer,
updateMCPServerState,
} from "./api";
import type { MCPScope } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
const { user } = useAuth();
const { data, isLoading, error } = useQuery({
queryKey: ["mcpConfig"],
queryFn: () => loadMCPConfig(),
queryKey: ["mcpConfig", "user", user?.id],
queryFn: () => loadMCPConfig("user"),
enabled: !!user,
retry: (count, error) =>
!(error instanceof MCPConfigRequestError) && count < 3,
});
@ -31,10 +36,13 @@ interface EnableMCPServerVariables {
enabled: boolean;
}
export function getEnableMCPServerMutationOptions(queryClient: QueryClient) {
export function getEnableMCPServerMutationOptions(
queryClient: QueryClient,
scope: MCPScope = "deployment",
) {
return {
mutationFn: ({ serverName, enabled }: EnableMCPServerVariables) =>
updateMCPServerState(serverName, enabled),
updateMCPServerState(serverName, enabled, scope),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
@ -49,7 +57,7 @@ export function getEnableMCPServerMutationOptions(queryClient: QueryClient) {
export function useEnableMCPServer() {
const queryClient = useQueryClient();
return useMutation(getEnableMCPServerMutationOptions(queryClient));
return useMutation(getEnableMCPServerMutationOptions(queryClient, "user"));
}
export type MCPServerMutationVariables =
@ -67,16 +75,19 @@ export type MCPServerMutationVariables =
serverName: string;
};
export function getMCPServerMutationOptions(queryClient: QueryClient) {
export function getMCPServerMutationOptions(
queryClient: QueryClient,
scope: MCPScope = "deployment",
) {
return {
mutationFn: (variables: MCPServerMutationVariables) => {
switch (variables.operation) {
case "create":
return createMCPServers(variables.servers);
return createMCPServers(variables.servers, scope);
case "update":
return updateMCPServer(variables.serverName, variables.server);
return updateMCPServer(variables.serverName, variables.server, scope);
case "delete":
return deleteMCPServer(variables.serverName);
return deleteMCPServer(variables.serverName, scope);
}
},
onSuccess: async () => {
@ -93,5 +104,5 @@ export function getMCPServerMutationOptions(queryClient: QueryClient) {
export function useMCPServerMutation() {
const queryClient = useQueryClient();
return useMutation(getMCPServerMutationOptions(queryClient));
return useMutation(getMCPServerMutationOptions(queryClient, "user"));
}

View File

@ -31,7 +31,7 @@ for (const plugin of [
let submission: Record<string, unknown> | undefined;
let configured = false;
const connectionName = `team-${plugin.id}`;
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: {
mcp_servers: configured
@ -82,6 +82,7 @@ for (const plugin of [
plugin_id: plugin.id,
name: connectionName,
configuration: plugin.fields,
scope: "user",
});
await expect(
page.locator("article").filter({ hasText: connectionName }),
@ -113,7 +114,7 @@ test("rejected credentials remain editable and are not marked configured", async
);
});
test("ordinary users see each shared connection once and cannot configure credentials", async ({
test("ordinary users see shared connections read-only and configure their own credentials", async ({
page,
}) => {
mockLangGraphAPI(page);
@ -141,8 +142,10 @@ test("ordinary users see each shared connection once and cannot configure creden
};
// Both adapters project the same MCP installation, not a second account.
for (const adapter of ["mcp", "business"]) {
await page.route(`**/api/capabilities/installations/${adapter}`, (route) =>
route.fulfill({ json: { items: [connection], can_manage: false } }),
await page.route(
`**/api/capabilities/installations/${adapter}?scope=all`,
(route) =>
route.fulfill({ json: { items: [connection], can_manage: false } }),
);
}
await page.goto("/workspace/capabilities");
@ -154,12 +157,41 @@ test("ordinary users see each shared connection once and cannot configure creden
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
const platform = page.getByRole("region", {
name: "Platform provided",
exact: true,
});
const personal = page.getByRole("region", {
name: "My plugins",
exact: true,
});
await expect(
platform.getByRole("button", {
name: "Setup guide Firecrawl",
exact: true,
}),
).toBeVisible();
await expect(
platform.locator("article").filter({ hasText: "Team CRM" }),
).toHaveCount(1);
await expect(
personal.locator("article").filter({ hasText: "Team CRM" }),
).toHaveCount(0);
await expect(
personal.getByRole("button", { name: "Add MCP plugin" }),
).toBeVisible();
await expect(
personal.locator("article").filter({ hasText: "HubSpot" }),
).toContainText("Not configured");
await expect(
page.locator("article").filter({ hasText: "Team CRM" }),
).toHaveCount(1);
await expect(
page.getByRole("button", { name: "Add MCP plugin" }),
page.locator("article").filter({ hasText: "Team CRM" }).getByRole("switch"),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Add MCP plugin" }),
).toHaveCount(1);
await page
.getByRole("button", {
name: "Configure WeCom group notifications",
@ -167,9 +199,9 @@ test("ordinary users see each shared connection once and cannot configure creden
})
.click();
const dialog = page.getByRole("dialog");
await expect(dialog.locator("#plugin-field-webhook_key")).toBeDisabled();
await expect(dialog.locator("#plugin-field-webhook_key")).toBeEnabled();
await expect(dialog.locator("#plugin-field-webhook_key")).toHaveValue("");
await expect(
dialog.getByRole("button", { name: "Save configuration" }),
).toBeDisabled();
).toBeEnabled();
});

View File

@ -53,7 +53,7 @@ const skills = [
async function mockCatalog(page: Page, locale = "en-US") {
mockLangGraphAPI(page, { skills, threads: [] });
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
@ -218,7 +218,7 @@ test("MCP access errors preserve the independently available Lark integration",
page,
}) => {
await mockCatalog(page);
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ status: 403, json: { detail: "Admin only" } }),
);
await page.goto("/workspace/capabilities");
@ -268,7 +268,7 @@ test("plugin filters remain usable after an MCP refetch fails", async ({
}) => {
await mockCatalog(page);
let failRead = false;
await page.route("**/api/mcp/config", async (route) => {
await page.route("**/api/mcp/personal/config", async (route) => {
if (route.request().method() === "PATCH") {
failRead = true;
return route.fulfill({ json: { mcp_servers: {} } });
@ -323,7 +323,7 @@ test("plugin categories, setup guides, and installed state remain distinct", asy
"研发与运维",
]) {
await expect(
page.getByRole("heading", { name, exact: true }),
page.getByRole("heading", { name, exact: true }).first(),
).toBeVisible();
}
await screenshot(page, "capability-catalog-zh.png");
@ -356,7 +356,7 @@ test("plugin categories, setup guides, and installed state remain distinct", asy
await page.getByRole("tab", { name: "已安装", exact: true }).click();
await expect(page.locator("article")).toHaveCount(0);
await expect(
page.getByText("没有找到匹配的内容", { exact: true }),
page.getByText("没有找到匹配的内容", { exact: true }).first(),
).toBeVisible();
await page.getByRole("textbox", { name: "搜索插件名称或用途" }).fill("");
await expect(page.locator("article")).toHaveCount(5);
@ -385,7 +385,7 @@ test("manifest installation saves through the adapter and refreshes the catalog"
mockLangGraphAPI(page);
let installed: Record<string, unknown> | null = null;
let submission: Record<string, unknown> | null = null;
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: { mcp_servers: installed ? { "team-code": installed } : {} },
}),
@ -443,7 +443,7 @@ test("agent selection saves explicit plugin IDs and an empty skill list", async
agents: [{ name: "analyst", description: "Analyze reports" }],
skills,
});
await page.route("**/api/capabilities/installations/mcp", (route) =>
await page.route("**/api/capabilities/installations/mcp?scope=all", (route) =>
route.fulfill({
json: {
can_manage: false,

View File

@ -15,7 +15,7 @@ for (const { viewport, name } of [
await page.setViewportSize(viewport);
mockLangGraphAPI(page);
const server = { enabled: false, command: "npx", args: ["example"] };
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ json: { mcp_servers: { [name]: server } } }),
);
await page.goto("/workspace/capabilities");
@ -109,7 +109,7 @@ for (const viewport of [
Array.from({ length: 60 }, (_, index) => [`TEST_${index}`, "example"]),
),
};
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ json: { mcp_servers: { github: server } } }),
);
await page.goto("/workspace/capabilities");
@ -196,14 +196,14 @@ test.describe("MCP server settings", () => {
| { server_name: string; server: (typeof servers)["remote"] }
| undefined;
await page.route("**/api/mcp/config", async (route) => {
await page.route("**/api/mcp/personal/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ mcp_servers: servers }),
});
});
await page.route("**/api/mcp/config/server", async (route) => {
await page.route("**/api/mcp/personal/config/server", async (route) => {
if (route.request().method() !== "PUT") {
await route.fallback();
return;

View File

@ -48,7 +48,7 @@ test("brand icons load locally for both recommendations and configured MCP serve
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
@ -137,10 +137,10 @@ test("upload previews, cancellation, save, reload, restore default, and create a
},
};
let writes = 0;
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ json: { mcp_servers: servers } }),
);
await page.route("**/api/mcp/config/server", async (route) => {
await page.route("**/api/mcp/personal/config/server", async (route) => {
writes++;
const body = route.request().postDataJSON() as {
server_name: string;
@ -149,7 +149,7 @@ test("upload previews, cancellation, save, reload, restore default, and create a
servers[body.server_name] = body.server;
await route.fulfill({ json: { mcp_servers: servers } });
});
await page.route("**/api/mcp/config/servers", async (route) => {
await page.route("**/api/mcp/personal/config/servers", async (route) => {
writes++;
const body = route.request().postDataJSON() as {
mcp_servers: Record<string, MCPServerConfig>;
@ -254,7 +254,7 @@ test("invalid or oversized uploads do not replace the existing icon", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
@ -322,7 +322,7 @@ test("a custom MCP name does not impersonate a catalog brand in rows or editing"
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
await page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({
json: {
mcp_servers: {

View File

@ -1859,7 +1859,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
void page.route("**/api/mcp/config", (route) =>
void page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ json: { mcp_servers: {} } }),
);
@ -1877,7 +1877,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
}),
);
void page.route("**/api/capabilities/installations/*", (route) => {
const adapter = route.request().url().split("/").pop();
const adapter = new URL(route.request().url()).pathname.split("/").pop();
const items =
adapter === "lark"
? [

View File

@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
const auth = rs.hoisted(() => ({ user: { id: "alice" } }));
rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: () => auth }));
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
import { fetch } from "@/core/api/fetcher";
import { useMCPConfig } from "@/core/mcp/hooks";
afterEach(cleanup);
describe("personal MCP ownership", () => {
it("does not display a previous account's late response after switching users", async () => {
auth.user = { id: "alice" };
let finishAlice!: (response: Response) => void;
rs.mocked(fetch).mockImplementation(async () => {
if (auth.user.id === "alice") {
return new Promise<Response>((resolve) => {
finishAlice = resolve;
});
}
return new Response(
JSON.stringify({ mcp_servers: { "bob-only": { enabled: true } } }),
);
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const hook = renderHook(() => useMCPConfig(), { wrapper });
await waitFor(() => expect(finishAlice).toBeDefined());
auth.user = { id: "bob" };
hook.rerender();
await waitFor(() =>
expect(hook.result.current.config?.mcp_servers).toHaveProperty(
"bob-only",
),
);
finishAlice(
new Response(
JSON.stringify({ mcp_servers: { "alice-only": { enabled: true } } }),
),
);
await waitFor(() =>
expect(client.getQueryData(["mcpConfig", "user", "alice"])).toBeDefined(),
);
expect(hook.result.current.config?.mcp_servers).not.toHaveProperty(
"alice-only",
);
expect(hook.result.current.config?.mcp_servers).toHaveProperty("bob-only");
expect(
rs
.mocked(fetch)
.mock.calls.every(
([url]) =>
typeof url === "string" && url.endsWith("/api/mcp/personal/config"),
),
).toBe(true);
client.clear();
});
});