mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
feat(mcp): manage servers from Settings (#5022)
* feat(mcp): manage servers from settings * fix(mcp): make settings updates targeted * fix(mcp): reject ambiguous masked array edits * fix(mcp): honor targeted server field deletions * fix(mcp): preserve OAuth extension secrets * fix(mcp): validate config before persistence * fix(mcp): preserve environment placeholders * fix(mcp): harden targeted configuration routes * docs: keep gateway guidance within budget * fix(mcp): protect per-tool override secrets * fix(mcp): keep disabled edits structurally safe
This commit is contained in:
parent
91c7ed4cf5
commit
340bff1107
@ -488,7 +488,7 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
|
||||
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
|
||||
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`; durable background-task calls honor the same setting for HTTP/SSE servers as well.
|
||||
MCP tool names are prefixed with `<server_name>_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers.
|
||||
Settings > Tools updates one MCP server at a time: 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.
|
||||
Settings > Tools 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.
|
||||
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.
|
||||
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.
|
||||
|
||||
@ -46,7 +46,7 @@ owner-scoped assistant version selection remains enabled.
|
||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
||||
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, and separate batch repository/worker states so history stays readable without a worker |
|
||||
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
|
||||
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
|
||||
| **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. Validate expanded, save raw; reload/reset; invalid -> 400. |
|
||||
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list; `GET /{name}` - inspect; `PUT /{name}` - toggle; `POST /install` - install a thread-local .skill archive; `POST /install/upload` - admin-only multipart, authorized before parsing and capped at a 100 MiB file plus 1 MiB framing; `POST /reload` - invalidate process-local cache after trusted filesystem changes |
|
||||
| **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. |
|
||||
|
||||
@ -4,10 +4,10 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, NamedTuple
|
||||
from typing import Any, Literal, NamedTuple, NoReturn
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||
|
||||
from app.gateway.deps import require_admin_user
|
||||
from deerflow.config.extensions_config import (
|
||||
@ -22,6 +22,7 @@ from deerflow.config.extensions_config import (
|
||||
normalize_mcp_transport_alias,
|
||||
reload_extensions_config,
|
||||
)
|
||||
from deerflow.config.runtime_paths import project_root
|
||||
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT
|
||||
from deerflow.mcp.cache import reset_mcp_tools_cache
|
||||
|
||||
@ -424,6 +425,9 @@ class McpOAuthConfigResponse(BaseModel):
|
||||
default_token_type: str = Field(default="Bearer", description="Default token type when response omits token_type")
|
||||
refresh_skew_seconds: int = Field(default=60, description="Refresh this many seconds before expiry")
|
||||
extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint")
|
||||
# Mirror the harness-side McpOAuthConfig (extra="allow"): provider-specific
|
||||
# OAuth fields must survive the Gateway's GET -> edit -> PUT round-trip.
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class McpServerConfigResponse(BaseModel):
|
||||
@ -506,12 +510,24 @@ class McpServerStateUpdateRequest(BaseModel):
|
||||
|
||||
server_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Name of the MCP server to update",
|
||||
)
|
||||
enabled: bool = Field(..., description="Whether the MCP server is enabled")
|
||||
|
||||
|
||||
class McpServerConfigUpdateRequest(BaseModel):
|
||||
"""Request model for replacing one MCP server configuration."""
|
||||
|
||||
server_name: str = Field(
|
||||
...,
|
||||
description="Name of the existing MCP server to update",
|
||||
)
|
||||
server: McpServerConfigResponse = Field(
|
||||
...,
|
||||
description="Complete replacement configuration for the selected MCP server",
|
||||
)
|
||||
|
||||
|
||||
class McpCacheResetResponse(BaseModel):
|
||||
"""Response model for resetting the MCP tools cache."""
|
||||
|
||||
@ -544,6 +560,66 @@ def _mask_sensitive_extra_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool:
|
||||
if value == _MASKED_VALUE and _is_sensitive_extra_key(key):
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_masked_sensitive_extra_value(str(nested_key), nested_value) for nested_key, nested_value in value.items())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_masked_sensitive_extra_value(key, item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_no_masked_secrets(server: McpServerConfigResponse) -> None:
|
||||
"""Reject request-only masked placeholders before config persistence."""
|
||||
|
||||
def reject(location: str) -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot persist masked secret placeholder for {location}; provide a real value.",
|
||||
)
|
||||
|
||||
for key, value in server.env.items():
|
||||
if value == _MASKED_VALUE:
|
||||
reject(f"env key '{key}'")
|
||||
for key, value in server.headers.items():
|
||||
if value == _MASKED_VALUE:
|
||||
reject(f"header '{key}'")
|
||||
for key, value in (server.model_extra or {}).items():
|
||||
if _contains_masked_sensitive_extra_value(str(key), value):
|
||||
reject(f"extra config key '{key}'")
|
||||
|
||||
if server.oauth is not None:
|
||||
if server.oauth.client_secret == _MASKED_VALUE:
|
||||
reject("oauth client_secret")
|
||||
if server.oauth.refresh_token == _MASKED_VALUE:
|
||||
reject("oauth refresh_token")
|
||||
for key, value in server.oauth.extra_token_params.items():
|
||||
if value == _MASKED_VALUE:
|
||||
reject(f"oauth extra_token_params key '{key}'")
|
||||
for key, value in (server.oauth.model_extra or {}).items():
|
||||
if _contains_masked_sensitive_extra_value(str(key), value):
|
||||
reject(f"oauth extra config key '{key}'")
|
||||
|
||||
if server.user_auth is not None:
|
||||
for key, value in server.user_auth.users.items():
|
||||
if value == _MASKED_VALUE:
|
||||
reject(f"user_auth credential '{key}'")
|
||||
for key, value in (server.user_auth.model_extra or {}).items():
|
||||
if _contains_masked_sensitive_extra_value(str(key), value):
|
||||
reject(f"user_auth extra config key '{key}'")
|
||||
|
||||
if server.headers_from_context is not None:
|
||||
for key, value in (server.headers_from_context.model_extra or {}).items():
|
||||
if _contains_masked_sensitive_extra_value(str(key), value):
|
||||
reject(f"headers_from_context extra config key '{key}'")
|
||||
|
||||
for tool_name, tool_override in server.tools.items():
|
||||
for key, value in (tool_override.model_extra or {}).items():
|
||||
if _contains_masked_sensitive_extra_value(str(key), value):
|
||||
reject(f"tools override '{tool_name}' extra config key '{key}'")
|
||||
|
||||
|
||||
def _merge_extra_value_preserving_masked(key: str, incoming_value: Any, existing_value: Any, *, existing_present: bool) -> Any:
|
||||
if incoming_value == _MASKED_VALUE and _is_sensitive_extra_key(key):
|
||||
if existing_present:
|
||||
@ -565,8 +641,15 @@ def _merge_extra_value_preserving_masked(key: str, incoming_value: Any, existing
|
||||
)
|
||||
return merged
|
||||
|
||||
if isinstance(incoming_value, list) and isinstance(existing_value, list) and len(incoming_value) == len(existing_value):
|
||||
return [_merge_extra_value_preserving_masked(key, nested_value, existing_value[index], existing_present=True) for index, nested_value in enumerate(incoming_value)]
|
||||
if isinstance(incoming_value, list) and isinstance(existing_value, list):
|
||||
if _contains_masked_sensitive_extra_value(key, incoming_value):
|
||||
if incoming_value != _mask_sensitive_extra_value(existing_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot edit extra config array '{key}' while masked secrets remain; provide real values for every masked secret.",
|
||||
)
|
||||
return existing_value
|
||||
return incoming_value
|
||||
|
||||
return incoming_value
|
||||
|
||||
@ -675,37 +758,43 @@ def _arbitrary_exec_arg(args: list[str], *, command: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_mcp_update_request(request: McpConfigUpdateRequest) -> None:
|
||||
def _validate_mcp_update_request(
|
||||
request: McpConfigUpdateRequest,
|
||||
*,
|
||||
enforce_execution_policy: bool = True,
|
||||
) -> None:
|
||||
"""Validate API-submitted MCP config before it is persisted.
|
||||
|
||||
Local config files can still express arbitrary advanced setups, but the
|
||||
HTTP API is an untrusted boundary. Restricting stdio commands here reduces
|
||||
the blast radius of a compromised authenticated browser session.
|
||||
|
||||
The command name alone is not a meaningful restriction, so the launcher's
|
||||
``args`` and ``env`` are screened for the flags and variables that turn an
|
||||
allowlisted binary into an arbitrary code evaluator.
|
||||
Command shape and code-injecting environment variables are invalid at the
|
||||
API boundary even while a server remains disabled. The allowlist and its
|
||||
companion argument screen are execution policy, so targeted offline edits
|
||||
may defer only those checks until the server is enabled.
|
||||
"""
|
||||
allowed_commands = _allowed_stdio_commands()
|
||||
allowed_commands = _allowed_stdio_commands() if enforce_execution_policy else set()
|
||||
for name, server in request.mcp_servers.items():
|
||||
transport_type = (server.type or "stdio").lower()
|
||||
if transport_type != "stdio":
|
||||
continue
|
||||
|
||||
command_name = _stdio_command_name(server.command, server_name=name)
|
||||
if command_name not in allowed_commands:
|
||||
allowed = ", ".join(sorted(allowed_commands)) or "<none>"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."),
|
||||
)
|
||||
if enforce_execution_policy:
|
||||
if command_name not in allowed_commands:
|
||||
allowed = ", ".join(sorted(allowed_commands)) or "<none>"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."),
|
||||
)
|
||||
|
||||
exec_flag = _arbitrary_exec_arg(server.args, command=command_name)
|
||||
if exec_flag is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{name}' passes '{exec_flag}' to '{command_name}', which would run arbitrary code. Point the server at a package or module instead."),
|
||||
)
|
||||
exec_flag = _arbitrary_exec_arg(server.args, command=command_name)
|
||||
if exec_flag is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{name}' passes '{exec_flag}' to '{command_name}', which would run arbitrary code. Point the server at a package or module instead."),
|
||||
)
|
||||
|
||||
for env_name in server.env:
|
||||
if env_name.strip().upper() in _CODE_INJECTING_ENV_VARS:
|
||||
@ -725,10 +814,17 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
|
||||
masked_headers = {k: _MASKED_VALUE for k in server.headers}
|
||||
masked_oauth = None
|
||||
if server.oauth is not None:
|
||||
# These values are arbitrary form fields sent directly to the token
|
||||
# endpoint. Treat the whole map as credential-bearing instead of
|
||||
# trying to recognize secrets from an open-ended key vocabulary.
|
||||
masked_extra_token_params = {key: _MASKED_VALUE for key in server.oauth.extra_token_params}
|
||||
masked_oauth_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.oauth.model_extra or {}).items()}
|
||||
masked_oauth = server.oauth.model_copy(
|
||||
update={
|
||||
"client_secret": None,
|
||||
"refresh_token": None,
|
||||
"extra_token_params": masked_extra_token_params,
|
||||
**masked_oauth_extra,
|
||||
}
|
||||
)
|
||||
masked_user_auth = None
|
||||
@ -747,6 +843,10 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
|
||||
# an operator store a secret-bearing key that round-trips through PUT.
|
||||
masked_ch_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.headers_from_context.model_extra or {}).items()}
|
||||
masked_headers_from_context = server.headers_from_context.model_copy(update=masked_ch_extra)
|
||||
masked_tools = {}
|
||||
for tool_name, tool_override in server.tools.items():
|
||||
masked_tool_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (tool_override.model_extra or {}).items()}
|
||||
masked_tools[tool_name] = tool_override.model_copy(update=masked_tool_extra)
|
||||
masked_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.model_extra or {}).items()}
|
||||
return server.model_copy(
|
||||
update={
|
||||
@ -755,6 +855,7 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
|
||||
"oauth": masked_oauth,
|
||||
"user_auth": masked_user_auth,
|
||||
"headers_from_context": masked_headers_from_context,
|
||||
"tools": masked_tools,
|
||||
**masked_extra,
|
||||
}
|
||||
)
|
||||
@ -763,6 +864,8 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
|
||||
def _merge_preserving_secrets(
|
||||
incoming: McpServerConfigResponse,
|
||||
existing: McpServerConfigResponse,
|
||||
*,
|
||||
preserve_omitted_fields: bool = True,
|
||||
) -> McpServerConfigResponse:
|
||||
"""Merge incoming config with existing, preserving secrets masked by GET.
|
||||
|
||||
@ -778,6 +881,11 @@ def _merge_preserving_secrets(
|
||||
so masked GET responses can be safely round-tripped. To explicitly clear
|
||||
a stored secret, clients may send an empty string, which is converted
|
||||
to ``None`` before persisting.
|
||||
|
||||
``preserve_omitted_fields`` keeps the legacy bulk PUT's partial-update
|
||||
behavior. Targeted PUT disables it because that endpoint is a complete
|
||||
replacement: omissions must delete/reset ordinary fields, while explicit
|
||||
masked placeholders still restore only their matching stored secrets.
|
||||
"""
|
||||
merged_env = {}
|
||||
for k, v in incoming.env.items():
|
||||
@ -806,93 +914,163 @@ def _merge_preserving_secrets(
|
||||
merged_headers[k] = v
|
||||
|
||||
merged_oauth = incoming.oauth
|
||||
if incoming.oauth is not None and existing.oauth is not None:
|
||||
# None = preserve (masked round-trip), "" = explicitly clear, else = new value
|
||||
merged_client_secret = existing.oauth.client_secret if incoming.oauth.client_secret is None else (None if incoming.oauth.client_secret == "" else incoming.oauth.client_secret)
|
||||
merged_refresh_token = existing.oauth.refresh_token if incoming.oauth.refresh_token is None else (None if incoming.oauth.refresh_token == "" else incoming.oauth.refresh_token)
|
||||
merged_oauth = incoming.oauth.model_copy(
|
||||
if incoming.oauth is not None:
|
||||
incoming_oauth = incoming.oauth
|
||||
base_oauth = existing.oauth
|
||||
base_extra_token_params = base_oauth.extra_token_params if base_oauth is not None else {}
|
||||
merged_extra_token_params: dict[str, str] = {}
|
||||
for key, value in incoming_oauth.extra_token_params.items():
|
||||
if value == _MASKED_VALUE:
|
||||
if key not in base_extra_token_params:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set oauth extra_token_params key '{key}' to masked value '***'; provide a real value.",
|
||||
)
|
||||
merged_extra_token_params[key] = base_extra_token_params[key]
|
||||
else:
|
||||
merged_extra_token_params[key] = value
|
||||
if preserve_omitted_fields and "extra_token_params" not in incoming_oauth.model_fields_set:
|
||||
merged_extra_token_params = dict(base_extra_token_params)
|
||||
|
||||
base_oauth_extra = (base_oauth.model_extra or {}) if base_oauth is not None else {}
|
||||
merged_oauth_extra: dict[str, Any] = {}
|
||||
for key, value in (incoming_oauth.model_extra or {}).items():
|
||||
merged_oauth_extra[key] = _merge_extra_value_preserving_masked(
|
||||
key,
|
||||
value,
|
||||
base_oauth_extra.get(key),
|
||||
existing_present=key in base_oauth_extra,
|
||||
)
|
||||
if preserve_omitted_fields:
|
||||
for key, value in base_oauth_extra.items():
|
||||
if key not in (incoming_oauth.model_extra or {}):
|
||||
merged_oauth_extra[key] = value
|
||||
|
||||
if base_oauth is not None:
|
||||
# None = preserve (masked round-trip), "" = explicitly clear,
|
||||
# else = new value.
|
||||
merged_client_secret = base_oauth.client_secret if incoming_oauth.client_secret is None else (None if incoming_oauth.client_secret == "" else incoming_oauth.client_secret)
|
||||
merged_refresh_token = base_oauth.refresh_token if incoming_oauth.refresh_token is None else (None if incoming_oauth.refresh_token == "" else incoming_oauth.refresh_token)
|
||||
else:
|
||||
merged_client_secret = incoming_oauth.client_secret
|
||||
merged_refresh_token = incoming_oauth.refresh_token
|
||||
merged_oauth = incoming_oauth.model_copy(
|
||||
update={
|
||||
"client_secret": merged_client_secret,
|
||||
"refresh_token": merged_refresh_token,
|
||||
"extra_token_params": merged_extra_token_params,
|
||||
**merged_oauth_extra,
|
||||
}
|
||||
)
|
||||
merged_user_auth = incoming.user_auth
|
||||
if incoming.user_auth is not None:
|
||||
# Sub-field-aware merge: a partial user_auth payload (e.g. just
|
||||
# {"enabled": false}) must not wipe the stored credential map or reset
|
||||
# other stored sub-fields. Only sub-fields the request explicitly set
|
||||
# replace stored values; the rest carry over — the same contract the
|
||||
# block-level `model_fields_set` check below applies one level up.
|
||||
incoming_ua = incoming.user_auth
|
||||
base = existing.user_auth
|
||||
set_fields = incoming_ua.model_fields_set
|
||||
effective: dict[str, Any] = {}
|
||||
if base is not None:
|
||||
effective.update({name: getattr(base, name) for name in ("enabled", "header", "users", "on_missing")})
|
||||
effective.update(base.model_extra or {})
|
||||
for name in ("enabled", "header", "on_missing"):
|
||||
if name in set_fields:
|
||||
effective[name] = getattr(incoming_ua, name)
|
||||
# Extras are masked by GET (see _mask_server_config), so a round-trip
|
||||
# PUT must swap masked sentinel values back for the stored ones —
|
||||
# the same contract server-level extras get below.
|
||||
base_extra = (base.model_extra or {}) if base is not None else {}
|
||||
merged_extra: dict[str, Any] = {}
|
||||
for key, value in (incoming_ua.model_extra or {}).items():
|
||||
effective[key] = _merge_extra_value_preserving_masked(
|
||||
merged_extra[key] = _merge_extra_value_preserving_masked(
|
||||
key,
|
||||
value,
|
||||
base_extra.get(key),
|
||||
existing_present=key in base_extra,
|
||||
)
|
||||
if "users" in set_fields:
|
||||
# An explicitly sent map replaces the stored one (so a full
|
||||
# round-trip can remove a user), with masked values swapped back
|
||||
# for the stored credentials.
|
||||
existing_users = base.users if base is not None else {}
|
||||
merged_users = {}
|
||||
for k, v in incoming_ua.users.items():
|
||||
if v == _MASKED_VALUE:
|
||||
if k in existing_users:
|
||||
merged_users[k] = existing_users[k]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set user_auth credential for '{k}' to masked value '***'; provide a real value.",
|
||||
)
|
||||
|
||||
existing_users = base.users if base is not None else {}
|
||||
merged_users = {}
|
||||
for k, v in incoming_ua.users.items():
|
||||
if v == _MASKED_VALUE:
|
||||
if k in existing_users:
|
||||
merged_users[k] = existing_users[k]
|
||||
else:
|
||||
merged_users[k] = v
|
||||
effective["users"] = merged_users
|
||||
merged_user_auth = McpUserScopedAuthConfigResponse(**effective)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set user_auth credential for '{k}' to masked value '***'; provide a real value.",
|
||||
)
|
||||
else:
|
||||
merged_users[k] = v
|
||||
|
||||
if preserve_omitted_fields:
|
||||
# A partial user_auth payload (for example only enabled=false)
|
||||
# inherits omitted sub-fields under the legacy bulk PUT contract.
|
||||
effective: dict[str, Any] = {}
|
||||
if base is not None:
|
||||
effective.update({name: getattr(base, name) for name in ("enabled", "header", "users", "on_missing")})
|
||||
effective.update(base_extra)
|
||||
for name in ("enabled", "header", "on_missing"):
|
||||
if name in set_fields:
|
||||
effective[name] = getattr(incoming_ua, name)
|
||||
effective.update(merged_extra)
|
||||
if "users" in set_fields:
|
||||
effective["users"] = merged_users
|
||||
merged_user_auth = McpUserScopedAuthConfigResponse(**effective)
|
||||
else:
|
||||
# Targeted PUT is a complete replacement. Start from the incoming
|
||||
# block so omitted ordinary sub-fields reset and omitted extras or
|
||||
# users disappear; only explicit masked values above are restored.
|
||||
merged_user_auth = incoming_ua.model_copy(
|
||||
update={"users": merged_users, **merged_extra},
|
||||
)
|
||||
|
||||
merged_context_headers = incoming.headers_from_context
|
||||
if incoming.headers_from_context is not None:
|
||||
# Sub-field-aware merge, mirroring user_auth above: an explicit partial
|
||||
# block (e.g. {"enabled": false}) must not wipe the stored mapping or
|
||||
# reset on_missing back to its default. Only fields the request set are
|
||||
# replaced, so an explicitly supplied ``headers`` (even {}) replaces the
|
||||
# map while omitted fields carry over from the stored block.
|
||||
incoming_ch = incoming.headers_from_context
|
||||
base_ch = existing.headers_from_context
|
||||
set_fields = incoming_ch.model_fields_set
|
||||
effective: dict[str, Any] = {}
|
||||
if base_ch is not None:
|
||||
effective.update({name: getattr(base_ch, name) for name in ("enabled", "headers", "on_missing")})
|
||||
effective.update(base_ch.model_extra or {})
|
||||
for name in ("enabled", "headers", "on_missing"):
|
||||
if name in set_fields:
|
||||
effective[name] = getattr(incoming_ch, name)
|
||||
# Extras are masked by GET (see _mask_server_config), so a round-trip
|
||||
# PUT must swap masked sentinel values back for the stored ones — the
|
||||
# same contract user_auth extras and server-level extras get.
|
||||
base_ch_extra = (base_ch.model_extra or {}) if base_ch is not None else {}
|
||||
merged_ch_extra: dict[str, Any] = {}
|
||||
for key, value in (incoming_ch.model_extra or {}).items():
|
||||
effective[key] = _merge_extra_value_preserving_masked(
|
||||
merged_ch_extra[key] = _merge_extra_value_preserving_masked(
|
||||
key,
|
||||
value,
|
||||
base_ch_extra.get(key),
|
||||
existing_present=key in base_ch_extra,
|
||||
)
|
||||
merged_context_headers = McpContextHeadersConfigResponse(**effective)
|
||||
|
||||
if preserve_omitted_fields:
|
||||
# The legacy bulk PUT accepts partial nested blocks. Only fields
|
||||
# the request set are replaced, while omitted fields carry over.
|
||||
effective: dict[str, Any] = {}
|
||||
if base_ch is not None:
|
||||
effective.update({name: getattr(base_ch, name) for name in ("enabled", "headers", "on_missing")})
|
||||
effective.update(base_ch_extra)
|
||||
for name in ("enabled", "headers", "on_missing"):
|
||||
if name in set_fields:
|
||||
effective[name] = getattr(incoming_ch, name)
|
||||
effective.update(merged_ch_extra)
|
||||
merged_context_headers = McpContextHeadersConfigResponse(**effective)
|
||||
else:
|
||||
# The targeted PUT is a complete replacement. Omitted ordinary
|
||||
# fields and extras reset/disappear; explicit masked extras alone
|
||||
# are restored from the stored block.
|
||||
merged_context_headers = incoming_ch.model_copy(update=merged_ch_extra)
|
||||
|
||||
merged_tools = {}
|
||||
for tool_name, incoming_tool in incoming.tools.items():
|
||||
base_tool = existing.tools.get(tool_name)
|
||||
base_tool_extra = (base_tool.model_extra or {}) if base_tool is not None else {}
|
||||
merged_tool_extra: dict[str, Any] = {}
|
||||
for key, value in (incoming_tool.model_extra or {}).items():
|
||||
merged_tool_extra[key] = _merge_extra_value_preserving_masked(
|
||||
key,
|
||||
value,
|
||||
base_tool_extra.get(key),
|
||||
existing_present=key in base_tool_extra,
|
||||
)
|
||||
if preserve_omitted_fields:
|
||||
for key, value in base_tool_extra.items():
|
||||
if key not in (incoming_tool.model_extra or {}):
|
||||
merged_tool_extra[key] = value
|
||||
merged_routing = incoming_tool.routing
|
||||
if preserve_omitted_fields and base_tool is not None and "routing" not in incoming_tool.model_fields_set:
|
||||
merged_routing = base_tool.routing
|
||||
merged_tools[tool_name] = incoming_tool.model_copy(
|
||||
update={"routing": merged_routing, **merged_tool_extra},
|
||||
)
|
||||
|
||||
update = {
|
||||
"env": merged_env,
|
||||
@ -900,14 +1078,15 @@ def _merge_preserving_secrets(
|
||||
"oauth": merged_oauth,
|
||||
"user_auth": merged_user_auth,
|
||||
"headers_from_context": merged_context_headers,
|
||||
"tools": merged_tools,
|
||||
}
|
||||
if "user_auth" not in incoming.model_fields_set:
|
||||
if preserve_omitted_fields and "user_auth" not in incoming.model_fields_set:
|
||||
update["user_auth"] = existing.user_auth
|
||||
if "headers_from_context" not in incoming.model_fields_set:
|
||||
if preserve_omitted_fields and "headers_from_context" not in incoming.model_fields_set:
|
||||
update["headers_from_context"] = existing.headers_from_context
|
||||
if "routing" not in incoming.model_fields_set:
|
||||
if preserve_omitted_fields and "routing" not in incoming.model_fields_set:
|
||||
update["routing"] = existing.routing
|
||||
if "tools" not in incoming.model_fields_set:
|
||||
if preserve_omitted_fields and "tools" not in incoming.model_fields_set:
|
||||
update["tools"] = existing.tools
|
||||
incoming_extra = incoming.model_extra or {}
|
||||
existing_extra = existing.model_extra or {}
|
||||
@ -918,10 +1097,13 @@ def _merge_preserving_secrets(
|
||||
existing_extra.get(key),
|
||||
existing_present=key in existing_extra,
|
||||
)
|
||||
for key, value in (existing.model_extra or {}).items():
|
||||
if key not in (incoming.model_extra or {}):
|
||||
update[key] = value
|
||||
return incoming.model_copy(update=update)
|
||||
if preserve_omitted_fields:
|
||||
for key, value in (existing.model_extra or {}).items():
|
||||
if key not in (incoming.model_extra or {}):
|
||||
update[key] = value
|
||||
merged = incoming.model_copy(update=update)
|
||||
_ensure_no_masked_secrets(merged)
|
||||
return merged
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -953,12 +1135,42 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
||||
"""
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
|
||||
config = get_extensions_config()
|
||||
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in config.mcp_servers.items()}
|
||||
raw_servers = await asyncio.to_thread(_load_raw_mcp_server_responses)
|
||||
servers = {name: _mask_server_config(server) for name, server in raw_servers.items()}
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
|
||||
|
||||
def _raise_invalid_mcp_configuration(detail: str, *, cause: Exception | None = None) -> NoReturn:
|
||||
error = HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid MCP configuration: {detail}",
|
||||
)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
||||
|
||||
def _validation_error_summary(exc: ValidationError) -> str:
|
||||
errors = exc.errors(include_url=False, include_input=False)
|
||||
return "; ".join(f"{'.'.join(str(part) for part in error['loc']) or 'config'}: {error['msg']}" for error in errors)
|
||||
|
||||
|
||||
def _mcp_server_response_from_raw(server_name: str, raw_server: Any) -> McpServerConfigResponse:
|
||||
try:
|
||||
return McpServerConfigResponse.model_validate(raw_server)
|
||||
except ValidationError as exc:
|
||||
_raise_invalid_mcp_configuration(f"mcpServers.{server_name}: {_validation_error_summary(exc)}", cause=exc)
|
||||
|
||||
|
||||
def _validate_extensions_config_candidate(raw_data: dict) -> None:
|
||||
"""Reject a runtime-invalid candidate without changing its placeholders."""
|
||||
try:
|
||||
resolved_data = ExtensionsConfig.resolve_env_variables(raw_data)
|
||||
ExtensionsConfig.model_validate(resolved_data)
|
||||
except ValidationError as exc:
|
||||
_raise_invalid_mcp_configuration(_validation_error_summary(exc), cause=exc)
|
||||
|
||||
|
||||
def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
|
||||
"""Worker-thread body for :func:`update_mcp_configuration`.
|
||||
|
||||
@ -972,38 +1184,37 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
|
||||
# same sidecar path for the complete read-modify-write cycle.
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
config_path = project_root() / "extensions_config.json"
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||||
# Load raw (un-resolved) JSON from disk to use as the merge source.
|
||||
# This preserves $VAR placeholders in env values and top-level keys
|
||||
# like mcpInterceptors that would otherwise be lost.
|
||||
raw_servers: dict[str, dict] = {}
|
||||
raw_data = _load_raw_extensions_config(config_path, create=True)
|
||||
raw_servers = _raw_mcp_servers(raw_data)
|
||||
raw_other_keys: dict = {}
|
||||
raw_skills: dict[str, dict] | None = None
|
||||
if config_path is not None and config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
raw_servers = raw_data.get("mcpServers", {})
|
||||
if isinstance(raw_data.get("skills"), dict):
|
||||
raw_skills = raw_data["skills"]
|
||||
# Preserve any top-level keys beyond mcpServers/skills
|
||||
for key, value in raw_data.items():
|
||||
if key not in ("mcpServers", "skills"):
|
||||
raw_other_keys[key] = value
|
||||
if isinstance(raw_data.get("skills"), dict):
|
||||
raw_skills = raw_data["skills"]
|
||||
# Preserve any top-level keys beyond mcpServers/skills
|
||||
for key, value in raw_data.items():
|
||||
if key not in ("mcpServers", "skills"):
|
||||
raw_other_keys[key] = value
|
||||
|
||||
# Merge incoming server configs with raw on-disk secrets
|
||||
merged_servers: dict[str, McpServerConfigResponse] = {}
|
||||
for name, incoming in body.mcp_servers.items():
|
||||
raw_server = raw_servers.get(name)
|
||||
if raw_server is not None:
|
||||
merged_servers[name] = _merge_preserving_secrets(
|
||||
merged = _merge_preserving_secrets(
|
||||
incoming,
|
||||
McpServerConfigResponse(**raw_server),
|
||||
_mcp_server_response_from_raw(name, raw_server),
|
||||
)
|
||||
else:
|
||||
merged_servers[name] = incoming
|
||||
merged = incoming
|
||||
_ensure_no_masked_secrets(merged)
|
||||
merged_servers[name] = merged
|
||||
|
||||
# Build config data preserving all top-level keys from the original file
|
||||
config_data = dict(raw_other_keys)
|
||||
@ -1013,6 +1224,7 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
|
||||
raw_skills = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
||||
config_data["skills"] = raw_skills
|
||||
|
||||
_validate_extensions_config_candidate(config_data)
|
||||
atomic_write_extensions_config(config_path, config_data)
|
||||
|
||||
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
||||
@ -1020,8 +1232,8 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
|
||||
# Reload the Gateway configuration and update the global cache. The
|
||||
# agent runtime lives in Gateway, so this keeps API reads and tool
|
||||
# execution aligned after extensions_config.json changes.
|
||||
reloaded_config = reload_extensions_config()
|
||||
return reloaded_config.mcp_servers
|
||||
reload_extensions_config()
|
||||
return _mcp_server_responses_from_raw(config_data)
|
||||
|
||||
|
||||
def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict:
|
||||
@ -1040,19 +1252,17 @@ def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict:
|
||||
detail=f"MCP server '{body.server_name}' not found",
|
||||
)
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
raw_servers = raw_data.get("mcpServers", {})
|
||||
raw_server = raw_servers.get(body.server_name) if isinstance(raw_servers, dict) else None
|
||||
if not isinstance(raw_server, dict):
|
||||
raw_data = _load_raw_extensions_config(config_path, create=False)
|
||||
raw_servers = _raw_mcp_servers(raw_data)
|
||||
if body.server_name not in raw_servers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"MCP server '{body.server_name}' not found",
|
||||
)
|
||||
raw_server = raw_servers[body.server_name]
|
||||
target_server = _mcp_server_response_from_raw(body.server_name, raw_server)
|
||||
|
||||
if body.enabled:
|
||||
target_server = McpServerConfigResponse(**raw_server)
|
||||
_validate_mcp_update_request(
|
||||
McpConfigUpdateRequest(
|
||||
mcp_servers={body.server_name: target_server},
|
||||
@ -1060,11 +1270,155 @@ def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict:
|
||||
)
|
||||
|
||||
raw_server["enabled"] = body.enabled
|
||||
_validate_extensions_config_candidate(raw_data)
|
||||
atomic_write_extensions_config(config_path, raw_data)
|
||||
|
||||
logger.info("MCP server %s enabled state updated to %s", body.server_name, body.enabled)
|
||||
reloaded_config = reload_extensions_config()
|
||||
return reloaded_config.mcp_servers
|
||||
reload_extensions_config()
|
||||
return _mcp_server_responses_from_raw(raw_data)
|
||||
|
||||
|
||||
def _mcp_config_path(*, create: bool) -> Path:
|
||||
"""Resolve the shared extensions config path for a targeted mutation."""
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
if create:
|
||||
config_path = project_root() / "extensions_config.json"
|
||||
logger.info("No existing extensions config found. Creating new config at: %s", config_path)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="MCP configuration not found",
|
||||
)
|
||||
return config_path
|
||||
|
||||
|
||||
def _load_raw_extensions_config(config_path: Path, *, create: bool) -> dict:
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
except json.JSONDecodeError as exc:
|
||||
_raise_invalid_mcp_configuration(
|
||||
f"Extensions configuration is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}",
|
||||
cause=exc,
|
||||
)
|
||||
if not isinstance(raw_data, dict):
|
||||
_raise_invalid_mcp_configuration("Extensions configuration must be a JSON object")
|
||||
return raw_data
|
||||
if not create:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="MCP configuration not found",
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _raw_mcp_servers(raw_data: dict) -> dict[str, dict]:
|
||||
raw_servers = raw_data.get("mcpServers", {})
|
||||
if not isinstance(raw_servers, dict):
|
||||
_raise_invalid_mcp_configuration("`mcpServers` must be a JSON object")
|
||||
return raw_servers
|
||||
|
||||
|
||||
def _mcp_server_responses_from_raw(raw_data: dict) -> dict[str, McpServerConfigResponse]:
|
||||
"""Build editable API models without expanding environment placeholders."""
|
||||
return {name: _mcp_server_response_from_raw(name, server) for name, server in _raw_mcp_servers(raw_data).items()}
|
||||
|
||||
|
||||
def _load_raw_mcp_server_responses() -> dict[str, McpServerConfigResponse]:
|
||||
"""Read editable MCP server definitions under the shared config lock."""
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
return {}
|
||||
|
||||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||||
raw_data = _load_raw_extensions_config(config_path, create=False)
|
||||
return _mcp_server_responses_from_raw(raw_data)
|
||||
|
||||
|
||||
def _ensure_skills_key(raw_data: dict) -> None:
|
||||
if isinstance(raw_data.get("skills"), dict):
|
||||
return
|
||||
current_config = get_extensions_config()
|
||||
raw_data["skills"] = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
||||
|
||||
|
||||
def _apply_mcp_servers_create(body: McpConfigUpdateRequest) -> dict:
|
||||
"""Atomically add servers without replacing entries already on disk."""
|
||||
config_path = _mcp_config_path(create=True)
|
||||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||||
raw_data = _load_raw_extensions_config(config_path, create=True)
|
||||
raw_servers = _raw_mcp_servers(raw_data)
|
||||
duplicate = next((name for name in body.mcp_servers if name in raw_servers), None)
|
||||
if duplicate is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"MCP server '{duplicate}' already exists",
|
||||
)
|
||||
|
||||
for name, incoming in body.mcp_servers.items():
|
||||
_ensure_no_masked_secrets(incoming)
|
||||
raw_servers[name] = incoming.model_dump()
|
||||
raw_data["mcpServers"] = raw_servers
|
||||
_ensure_skills_key(raw_data)
|
||||
_validate_extensions_config_candidate(raw_data)
|
||||
atomic_write_extensions_config(config_path, raw_data)
|
||||
|
||||
logger.info("Added MCP servers: %s", ", ".join(body.mcp_servers))
|
||||
reload_extensions_config()
|
||||
return _mcp_server_responses_from_raw(raw_data)
|
||||
|
||||
|
||||
def _apply_mcp_server_config_update(body: McpServerConfigUpdateRequest) -> dict:
|
||||
"""Atomically replace one server while preserving concurrent sibling edits."""
|
||||
config_path = _mcp_config_path(create=False)
|
||||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||||
raw_data = _load_raw_extensions_config(config_path, create=False)
|
||||
raw_servers = _raw_mcp_servers(raw_data)
|
||||
if body.server_name not in raw_servers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"MCP server '{body.server_name}' not found",
|
||||
)
|
||||
existing_server = _mcp_server_response_from_raw(body.server_name, raw_servers[body.server_name])
|
||||
|
||||
merged = _merge_preserving_secrets(
|
||||
body.server,
|
||||
existing_server,
|
||||
preserve_omitted_fields=False,
|
||||
)
|
||||
_ensure_no_masked_secrets(merged)
|
||||
raw_servers[body.server_name] = merged.model_dump()
|
||||
raw_data["mcpServers"] = raw_servers
|
||||
_validate_extensions_config_candidate(raw_data)
|
||||
atomic_write_extensions_config(config_path, raw_data)
|
||||
|
||||
logger.info("Updated MCP server: %s", body.server_name)
|
||||
reload_extensions_config()
|
||||
return _mcp_server_responses_from_raw(raw_data)
|
||||
|
||||
|
||||
def _apply_mcp_server_delete(server_name: str) -> dict:
|
||||
"""Atomically remove one server while preserving every sibling entry."""
|
||||
config_path = _mcp_config_path(create=False)
|
||||
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
||||
raw_data = _load_raw_extensions_config(config_path, create=False)
|
||||
raw_servers = _raw_mcp_servers(raw_data)
|
||||
if server_name not in raw_servers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"MCP server '{server_name}' not found",
|
||||
)
|
||||
|
||||
del raw_servers[server_name]
|
||||
raw_data["mcpServers"] = raw_servers
|
||||
_validate_extensions_config_candidate(raw_data)
|
||||
atomic_write_extensions_config(config_path, raw_data)
|
||||
|
||||
logger.info("Deleted MCP server: %s", server_name)
|
||||
reload_extensions_config()
|
||||
return _mcp_server_responses_from_raw(raw_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -1148,6 +1502,77 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/config/servers",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Add MCP Servers",
|
||||
description="Add one or more MCP servers without replacing existing configurations.",
|
||||
)
|
||||
async def create_mcp_servers(request: Request, body: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||
"""Add servers atomically and reject names that already exist."""
|
||||
try:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
_validate_mcp_update_request(body)
|
||||
reloaded_servers = await asyncio.to_thread(_apply_mcp_servers_create, body)
|
||||
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
||||
reset_mcp_tools_cache()
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to add MCP servers: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add MCP servers: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/mcp/config/server",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Update MCP Server",
|
||||
description="Replace one MCP server without replacing sibling configurations.",
|
||||
)
|
||||
async def update_mcp_server(request: Request, body: McpServerConfigUpdateRequest) -> McpConfigResponse:
|
||||
"""Update one existing server and reload the MCP tool cache."""
|
||||
try:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
_validate_mcp_update_request(
|
||||
McpConfigUpdateRequest(mcp_servers={body.server_name: body.server}),
|
||||
enforce_execution_policy=body.server.enabled,
|
||||
)
|
||||
reloaded_servers = await asyncio.to_thread(_apply_mcp_server_config_update, body)
|
||||
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
||||
reset_mcp_tools_cache()
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update MCP server %s: %s", body.server_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update MCP server: {str(e)}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mcp/config/servers/{server_name:path}",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Delete MCP Server",
|
||||
description="Delete one MCP server without replacing sibling configurations.",
|
||||
)
|
||||
async def delete_mcp_server(request: Request, server_name: str) -> McpConfigResponse:
|
||||
"""Delete one existing server and reload the MCP tool cache."""
|
||||
try:
|
||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||
reloaded_servers = await asyncio.to_thread(_apply_mcp_server_delete, server_name)
|
||||
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
||||
reset_mcp_tools_cache()
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete MCP server %s: %s", server_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete MCP server: {str(e)}")
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/mcp/config",
|
||||
response_model=McpConfigResponse,
|
||||
|
||||
@ -437,7 +437,10 @@ GET /api/mcp/config
|
||||
```
|
||||
|
||||
Requires an authenticated admin session. Sensitive env/header/OAuth secret
|
||||
values are masked in the response.
|
||||
values are masked in the response. Environment placeholders outside secret
|
||||
containers are returned in their raw form so editing cannot expose or persist
|
||||
their expanded values. Invalid operator-authored JSON/config shapes return
|
||||
`400` instead of being reported as a Gateway fault.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
@ -537,6 +540,63 @@ The response is the full masked MCP configuration, matching `GET` and `PUT`.
|
||||
An unknown `server_name` returns `404`; attempting to enable a server with a
|
||||
disallowed `stdio` command returns `400`.
|
||||
|
||||
#### Add MCP Servers
|
||||
|
||||
Add one or more servers without replacing existing entries. The Gateway
|
||||
re-reads the file under the shared configuration lock, so concurrent sibling
|
||||
changes are preserved. Existing names return `409`.
|
||||
|
||||
```http
|
||||
POST /api/mcp/config/servers
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
The request body uses the same `mcp_servers` map as the full `PUT` endpoint.
|
||||
|
||||
#### Replace One MCP Server
|
||||
|
||||
Completely replace one existing server while preserving sibling entries.
|
||||
Omitted ordinary fields are deleted or reset; explicit `***` placeholders
|
||||
restore the corresponding stored secret.
|
||||
|
||||
A disabled `stdio` replacement may keep a syntactically valid command outside
|
||||
the allowlist for offline editing. Command-shape and code-injecting environment
|
||||
variable checks still run when saving; the allowlist and executable-argument
|
||||
policy run when the server is enabled.
|
||||
|
||||
```http
|
||||
PUT /api/mcp/config/server
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"server_name": "github",
|
||||
"server": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "***"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete One MCP Server
|
||||
|
||||
Delete one server without replacing sibling entries. The server name is a
|
||||
path parameter and the DELETE request has no body. Percent-encode names before
|
||||
placing them in the URL; the path converter also keeps legacy empty and
|
||||
slash-containing names addressable.
|
||||
|
||||
```http
|
||||
DELETE /api/mcp/config/servers/{server_name}
|
||||
```
|
||||
|
||||
All targeted mutations return the full masked MCP configuration. Before any
|
||||
write, the Gateway resolves environment variables in a copy and validates the
|
||||
same expanded document the runtime will load while persisting the original raw
|
||||
placeholders.
|
||||
|
||||
#### Reset MCP Tools Cache
|
||||
|
||||
Clear cached MCP tools and persistent MCP sessions process-wide. This affects
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""Regression anchor: updating MCP config must not block the event loop.
|
||||
"""Regression anchor: reading or updating MCP config must not block the event loop.
|
||||
|
||||
The PUT and PATCH handlers resolve the extensions config path, probe its
|
||||
existence, read raw JSON, atomically write it, and reload it — all blocking
|
||||
filesystem IO. They offload the whole read-modify-write via
|
||||
``asyncio.to_thread``; if either regresses back onto the event loop, the strict
|
||||
The GET handler resolves the extensions config path and reads raw JSON. PUT
|
||||
and PATCH also atomically write and reload it. All of that is blocking
|
||||
filesystem IO, so the handlers offload the read or whole read-modify-write via
|
||||
``asyncio.to_thread``. If one regresses back onto the event loop, the strict
|
||||
Blockbuster gate raises ``BlockingError`` and this test fails.
|
||||
|
||||
The admin check is patched to a no-op so the anchor exercises the handler's own
|
||||
@ -26,6 +26,7 @@ from app.gateway.routers.mcp import (
|
||||
McpConfigUpdateRequest,
|
||||
McpServerConfigResponse,
|
||||
McpServerStateUpdateRequest,
|
||||
get_mcp_configuration,
|
||||
update_mcp_configuration,
|
||||
update_mcp_server_state,
|
||||
)
|
||||
@ -33,6 +34,27 @@ from app.gateway.routers.mcp import (
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_get_mcp_configuration_does_not_block_or_expand_placeholders(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "extensions_config.json"
|
||||
placeholder = "$CODEX_PR_5022_BLOCKING_TOKEN"
|
||||
await asyncio.to_thread(
|
||||
config_path.write_text,
|
||||
'{"mcpServers":{"stdio":{"type":"stdio","command":"npx","args":["--token","' + placeholder + '"]}},"skills":{}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("CODEX_PR_5022_BLOCKING_TOKEN", "must-not-reach-the-editor")
|
||||
|
||||
async def _noop_admin(_request, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(mcp_router, "require_admin_user", _noop_admin)
|
||||
|
||||
response = await get_mcp_configuration(request=None)
|
||||
|
||||
assert response.mcp_servers["stdio"].args == ["--token", placeholder]
|
||||
|
||||
|
||||
async def test_update_mcp_configuration_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "extensions_config.json"
|
||||
# resolve_config_path() requires the env-pointed file to exist; seed a minimal one.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -955,6 +955,46 @@ def test_gateway_keeps_block_extras_a_put_does_not_mention():
|
||||
assert merged.headers_from_context.model_extra["vendor_note"] == "keep-me"
|
||||
|
||||
|
||||
def test_gateway_complete_replacement_resets_omitted_block_fields_and_extras():
|
||||
"""Targeted PUT keeps only explicitly masked secrets from the stored block."""
|
||||
from app.gateway.routers.mcp import (
|
||||
McpContextHeadersConfigResponse,
|
||||
McpServerConfigResponse,
|
||||
_merge_preserving_secrets,
|
||||
)
|
||||
|
||||
existing = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://mcp.example.com/mcp",
|
||||
headers_from_context=McpContextHeadersConfigResponse(
|
||||
headers={"X-Tenant-Token": "tenant_token"},
|
||||
on_missing="passthrough",
|
||||
api_key="real-secret",
|
||||
vendor_note="remove-me",
|
||||
),
|
||||
)
|
||||
incoming = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://mcp.example.com/mcp",
|
||||
headers_from_context=McpContextHeadersConfigResponse(
|
||||
enabled=False,
|
||||
api_key="***",
|
||||
),
|
||||
)
|
||||
|
||||
merged = _merge_preserving_secrets(
|
||||
incoming,
|
||||
existing,
|
||||
preserve_omitted_fields=False,
|
||||
)
|
||||
|
||||
assert merged.headers_from_context is not None
|
||||
assert merged.headers_from_context.enabled is False
|
||||
assert merged.headers_from_context.headers == {}
|
||||
assert merged.headers_from_context.on_missing == "deny"
|
||||
assert merged.headers_from_context.model_extra == {"api_key": "real-secret"}
|
||||
|
||||
|
||||
def test_gateway_rejects_a_masked_value_for_an_unknown_block_extra():
|
||||
"""A sentinel with nothing stored behind it must not be written to disk."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
@ -312,6 +312,46 @@ def test_explicit_users_map_still_replaces_and_can_remove():
|
||||
assert merged.user_auth.users == {"u1": "Bearer s1"} # u2 removed, u1 preserved through mask
|
||||
|
||||
|
||||
def test_complete_replacement_user_auth_honors_omitted_subfields():
|
||||
from app.gateway.routers.mcp import (
|
||||
McpServerConfigResponse,
|
||||
McpUserScopedAuthConfigResponse,
|
||||
_merge_preserving_secrets,
|
||||
)
|
||||
|
||||
existing = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://x",
|
||||
user_auth=McpUserScopedAuthConfigResponse(
|
||||
header="X-Api-Key",
|
||||
users={"u1": "Bearer s1", "u2": "Bearer s2"},
|
||||
on_missing="passthrough",
|
||||
custom_note="remove-me",
|
||||
),
|
||||
)
|
||||
incoming = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://x",
|
||||
user_auth=McpUserScopedAuthConfigResponse(
|
||||
enabled=False,
|
||||
users={"u1": "***"},
|
||||
),
|
||||
)
|
||||
|
||||
merged = _merge_preserving_secrets(
|
||||
incoming,
|
||||
existing,
|
||||
preserve_omitted_fields=False,
|
||||
)
|
||||
|
||||
assert merged.user_auth is not None
|
||||
assert merged.user_auth.enabled is False
|
||||
assert merged.user_auth.header == "Authorization"
|
||||
assert merged.user_auth.users == {"u1": "Bearer s1"}
|
||||
assert merged.user_auth.on_missing == "deny"
|
||||
assert "custom_note" not in (merged.user_auth.model_extra or {})
|
||||
|
||||
|
||||
def test_user_auth_extra_keys_survive_parse_mask_and_merge():
|
||||
from app.gateway.routers.mcp import (
|
||||
McpServerConfigResponse,
|
||||
@ -332,6 +372,42 @@ def test_user_auth_extra_keys_survive_parse_mask_and_merge():
|
||||
assert (merged.user_auth.model_extra or {}).get("custom_note") == "keep-me"
|
||||
|
||||
|
||||
def test_user_auth_extra_array_rejects_structural_edit_while_secrets_are_masked():
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.mcp import (
|
||||
McpServerConfigResponse,
|
||||
McpUserScopedAuthConfigResponse,
|
||||
_mask_server_config,
|
||||
_merge_preserving_secrets,
|
||||
)
|
||||
|
||||
existing = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://x",
|
||||
user_auth=McpUserScopedAuthConfigResponse(
|
||||
providers=[
|
||||
{"name": "alpha", "apiKey": "secret-alpha"},
|
||||
{"name": "beta", "apiKey": "secret-beta"},
|
||||
]
|
||||
),
|
||||
)
|
||||
masked = _mask_server_config(existing)
|
||||
incoming = McpServerConfigResponse(
|
||||
type="http",
|
||||
url="https://x",
|
||||
user_auth=McpUserScopedAuthConfigResponse(
|
||||
providers=list(reversed(masked.user_auth.model_extra["providers"])),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_merge_preserving_secrets(incoming, existing)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "providers" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_stdio_server_user_auth_is_skipped_with_warning(caplog):
|
||||
import logging
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@
|
||||
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
|
||||
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
|
||||
trigger is hidden for new/mock/static-demo threads and unless `/api/features`
|
||||
reports the startup-scoped `mcp_tasks` capability; the list query is disabled
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { PencilIcon, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@ -8,9 +20,19 @@ import {
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { MCPConfigRequestError } from "@/core/mcp/api";
|
||||
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
||||
import {
|
||||
useEnableMCPServer,
|
||||
useMCPConfig,
|
||||
useMCPServerMutation,
|
||||
} from "@/core/mcp/hooks";
|
||||
import {
|
||||
formatMCPServerDefinition,
|
||||
MCPServerDefinitionError,
|
||||
parseMCPServerDefinition,
|
||||
} from "@/core/mcp/parse";
|
||||
import type { MCPServerConfig } from "@/core/mcp/types";
|
||||
import { env } from "@/env";
|
||||
|
||||
@ -48,41 +70,267 @@ function MCPServerList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isPending, mutate: enableMCPServer } = useEnableMCPServer();
|
||||
const entries = Object.entries(servers ?? {});
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.tools.empty}
|
||||
</div>
|
||||
const { isPending: isWriting, mutate: mutateServer } = useMCPServerMutation();
|
||||
const [editor, setEditor] = useState<
|
||||
{ mode: "add" } | { mode: "edit"; name: string } | null
|
||||
>(null);
|
||||
const [definition, setDefinition] = useState("");
|
||||
const [definitionError, setDefinitionError] = useState<string | null>(null);
|
||||
const [pendingRemoval, setPendingRemoval] = useState<string | null>(null);
|
||||
|
||||
const readOnly = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
const current = servers ?? {};
|
||||
const entries = Object.entries(current);
|
||||
const isMutating = isPending || isWriting;
|
||||
|
||||
function displayServerName(name: string | null) {
|
||||
return name === null || name.length === 0
|
||||
? t.settings.tools.unnamedServer
|
||||
: name;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditor(null);
|
||||
setDefinition("");
|
||||
setDefinitionError(null);
|
||||
}
|
||||
|
||||
function openAddEditor() {
|
||||
setDefinition("");
|
||||
setDefinitionError(null);
|
||||
setEditor({ mode: "add" });
|
||||
}
|
||||
|
||||
function openEditEditor(name: string, config: MCPServerConfig) {
|
||||
setDefinition(formatMCPServerDefinition(name, config));
|
||||
setDefinitionError(null);
|
||||
setEditor({ mode: "edit", name });
|
||||
}
|
||||
|
||||
function handleSaveDefinition() {
|
||||
if (editor === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: Record<string, MCPServerConfig>;
|
||||
try {
|
||||
parsed = parseMCPServerDefinition(definition);
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof MCPServerDefinitionError) {
|
||||
const messages = {
|
||||
emptyDefinition: t.settings.tools.definitionEmpty,
|
||||
invalidJson: t.settings.tools.definitionInvalidJson,
|
||||
rootNotObject: t.settings.tools.definitionRootNotObject,
|
||||
emptyServerMap: t.settings.tools.definitionNoServers,
|
||||
serverConfigNotObject:
|
||||
t.settings.tools.definitionServerNotObject.replace(
|
||||
"{name}",
|
||||
parseError.serverName ?? "",
|
||||
),
|
||||
};
|
||||
setDefinitionError(messages[parseError.code]);
|
||||
} else {
|
||||
setDefinitionError(t.settings.tools.definitionInvalidJson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.mode === "add") {
|
||||
const duplicate = Object.keys(parsed).find((name) =>
|
||||
Object.hasOwn(current, name),
|
||||
);
|
||||
if (duplicate !== undefined) {
|
||||
setDefinitionError(
|
||||
t.settings.tools.serverAlreadyExists.replace("{name}", duplicate),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDefinitionError(null);
|
||||
mutateServer(
|
||||
{ operation: "create", servers: parsed },
|
||||
{ onSuccess: closeEditor },
|
||||
);
|
||||
} else {
|
||||
const editedEntries = Object.entries(parsed);
|
||||
if (editedEntries.length !== 1) {
|
||||
setDefinitionError(t.settings.tools.editSingleServer);
|
||||
return;
|
||||
}
|
||||
const [editedName, editedConfig] = editedEntries[0]!;
|
||||
if (editedName !== editor.name) {
|
||||
setDefinitionError(
|
||||
t.settings.tools.editServerNameMismatch.replace(
|
||||
"{name}",
|
||||
editor.name,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDefinitionError(null);
|
||||
mutateServer(
|
||||
{
|
||||
operation: "update",
|
||||
serverName: editor.name,
|
||||
server: editedConfig,
|
||||
},
|
||||
{ onSuccess: closeEditor },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(name: string) {
|
||||
mutateServer(
|
||||
{ operation: "delete", serverName: name },
|
||||
{ onSuccess: () => setPendingRemoval(null) },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{entries.map(([name, config]) => (
|
||||
<Item className="w-full" variant="outline" key={name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{config.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isPending
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={readOnly || isMutating}
|
||||
onClick={openAddEditor}
|
||||
>
|
||||
{t.settings.tools.addServer}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.settings.tools.empty}
|
||||
</div>
|
||||
) : (
|
||||
entries.map(([name, config]) => {
|
||||
const displayName = displayServerName(name);
|
||||
return (
|
||||
<Item className="w-full" variant="outline" key={name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{displayName}</div>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{config.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="gap-1">
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={readOnly || isMutating}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${t.common.edit} ${displayName}`}
|
||||
disabled={readOnly || isMutating}
|
||||
onClick={() => openEditEditor(name, config)}
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${t.common.delete} ${displayName}`}
|
||||
disabled={readOnly || isMutating}
|
||||
onClick={() => setPendingRemoval(name)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editor !== null}
|
||||
onOpenChange={(open) => !open && !isWriting && closeEditor()}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editor?.mode === "edit"
|
||||
? t.settings.tools.editServer
|
||||
: t.settings.tools.addServer}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editor?.mode === "edit"
|
||||
? t.settings.tools.editServerDescription.replace(
|
||||
"{name}",
|
||||
editor.name,
|
||||
)
|
||||
: t.settings.tools.addServerDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="min-h-52 font-mono text-xs"
|
||||
aria-label={t.settings.tools.serverDefinitionLabel}
|
||||
spellCheck={false}
|
||||
value={definition}
|
||||
placeholder={t.settings.tools.addServerPlaceholder}
|
||||
onChange={(event) => setDefinition(event.target.value)}
|
||||
/>
|
||||
{definitionError && (
|
||||
<div className="text-destructive text-sm" role="alert">
|
||||
{definitionError}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isWriting}
|
||||
onClick={closeEditor}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={isWriting} onClick={handleSaveDefinition}>
|
||||
{isWriting ? t.common.loading : t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={pendingRemoval !== null}
|
||||
onOpenChange={(open) => !open && setPendingRemoval(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.tools.removeServer}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.tools.removeServerDescription.replace(
|
||||
"{name}",
|
||||
displayServerName(pendingRemoval),
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isWriting}
|
||||
onClick={() => setPendingRemoval(null)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isWriting}
|
||||
onClick={() =>
|
||||
pendingRemoval !== null && handleRemove(pendingRemoval)
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
>
|
||||
{isWriting ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -952,6 +952,37 @@ export const enUS: Translations = {
|
||||
description: "Manage the configuration and enabled status of MCP tools.",
|
||||
adminRequired: "Admin privileges are required to manage MCP tools.",
|
||||
empty: "No MCP tools configured.",
|
||||
addServer: "Add server",
|
||||
addServerDescription:
|
||||
"Paste the JSON definition published by the MCP server. Both a bare server map and one wrapped in `mcpServers` are accepted. Existing names must be changed through Edit.",
|
||||
addServerPlaceholder: `{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my-org/my-mcp-server"]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
serverDefinitionLabel: "MCP server JSON definition",
|
||||
definitionEmpty: "Paste an MCP server definition.",
|
||||
definitionInvalidJson: "Enter valid JSON.",
|
||||
definitionRootNotObject:
|
||||
"Enter a JSON object describing one or more MCP servers.",
|
||||
definitionNoServers: "No MCP server was found in the definition.",
|
||||
definitionServerNotObject:
|
||||
'The configuration for server "{name}" must be a JSON object.',
|
||||
editServer: "Edit MCP server",
|
||||
editServerDescription:
|
||||
'Edit the complete JSON definition for "{name}". The server name is fixed; add a new server and remove this one to rename it.',
|
||||
editSingleServer: "Edit exactly one MCP server at a time.",
|
||||
editServerNameMismatch:
|
||||
'Keep the existing server name "{name}" while editing.',
|
||||
serverAlreadyExists:
|
||||
'MCP server "{name}" already exists. Use Edit instead.',
|
||||
removeServer: "Remove MCP server",
|
||||
removeServerDescription:
|
||||
'Remove "{name}" from the MCP configuration? Its tools stop being available to agents.',
|
||||
unnamedServer: "(empty name)",
|
||||
},
|
||||
subagents: {
|
||||
title: "Subagents",
|
||||
|
||||
@ -812,6 +812,23 @@ export interface Translations {
|
||||
description: string;
|
||||
adminRequired: string;
|
||||
empty: string;
|
||||
addServer: string;
|
||||
addServerDescription: string;
|
||||
addServerPlaceholder: string;
|
||||
serverDefinitionLabel: string;
|
||||
definitionEmpty: string;
|
||||
definitionInvalidJson: string;
|
||||
definitionRootNotObject: string;
|
||||
definitionNoServers: string;
|
||||
definitionServerNotObject: string;
|
||||
editServer: string;
|
||||
editServerDescription: string;
|
||||
editSingleServer: string;
|
||||
editServerNameMismatch: string;
|
||||
serverAlreadyExists: string;
|
||||
removeServer: string;
|
||||
removeServerDescription: string;
|
||||
unnamedServer: string;
|
||||
};
|
||||
subagents: {
|
||||
title: string;
|
||||
|
||||
@ -915,6 +915,33 @@ export const zhCN: Translations = {
|
||||
description: "管理 MCP 工具的配置和启用状态。",
|
||||
adminRequired: "需要管理员权限才能管理 MCP 工具。",
|
||||
empty: "暂无 MCP 工具。",
|
||||
addServer: "添加服务器",
|
||||
addServerDescription:
|
||||
"粘贴 MCP 服务器提供的 JSON 定义。直接的服务器映射和带 `mcpServers` 外层的写法都可以。已有名称请通过“编辑”修改。",
|
||||
addServerPlaceholder: `{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my-org/my-mcp-server"]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
serverDefinitionLabel: "MCP 服务器 JSON 定义",
|
||||
definitionEmpty: "请粘贴 MCP 服务器定义。",
|
||||
definitionInvalidJson: "请输入有效的 JSON。",
|
||||
definitionRootNotObject: "请输入描述一个或多个 MCP 服务器的 JSON 对象。",
|
||||
definitionNoServers: "定义中未找到 MCP 服务器。",
|
||||
definitionServerNotObject: "服务器“{name}”的配置必须是 JSON 对象。",
|
||||
editServer: "编辑 MCP 服务器",
|
||||
editServerDescription:
|
||||
"编辑“{name}”的完整 JSON 定义。服务器名称不可修改;如需重命名,请添加新服务器后移除当前服务器。",
|
||||
editSingleServer: "每次只能编辑一个 MCP 服务器。",
|
||||
editServerNameMismatch: "编辑时请保留现有服务器名称“{name}”。",
|
||||
serverAlreadyExists: "MCP 服务器“{name}”已存在,请使用“编辑”。",
|
||||
removeServer: "移除 MCP 服务器",
|
||||
removeServerDescription:
|
||||
"确定从 MCP 配置中移除“{name}”吗?它的工具将不再提供给智能体。",
|
||||
unnamedServer: "(空名称)",
|
||||
},
|
||||
subagents: {
|
||||
title: "子智能体",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { MCPConfig } from "./types";
|
||||
import type { MCPConfig, MCPServerConfig } from "./types";
|
||||
|
||||
export class MCPConfigRequestError extends Error {
|
||||
readonly status: number;
|
||||
@ -53,6 +53,56 @@ export async function updateMCPConfig(config: MCPConfig) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function mutateMCPServerConfig(
|
||||
path: string,
|
||||
method: "POST" | "PUT" | "DELETE",
|
||||
body: unknown | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const request: RequestInit = { method };
|
||||
if (body !== undefined) {
|
||||
request.headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
request.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(`${getBackendBaseURL()}${path}`, request);
|
||||
if (!response.ok) {
|
||||
throw new MCPConfigRequestError(
|
||||
response.status,
|
||||
await readErrorDetail(response, fallback),
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<MCPConfig>;
|
||||
}
|
||||
|
||||
export function createMCPServers(servers: Record<string, MCPServerConfig>) {
|
||||
return mutateMCPServerConfig(
|
||||
"/api/mcp/config/servers",
|
||||
"POST",
|
||||
{ mcp_servers: servers },
|
||||
"Failed to add MCP servers",
|
||||
);
|
||||
}
|
||||
|
||||
export function updateMCPServer(serverName: string, server: MCPServerConfig) {
|
||||
return mutateMCPServerConfig(
|
||||
"/api/mcp/config/server",
|
||||
"PUT",
|
||||
{ server_name: serverName, server },
|
||||
"Failed to update MCP server",
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteMCPServer(serverName: string) {
|
||||
return mutateMCPServerConfig(
|
||||
`/api/mcp/config/servers/${encodeURIComponent(serverName)}`,
|
||||
"DELETE",
|
||||
undefined,
|
||||
"Failed to delete MCP server",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMCPServerState(
|
||||
serverName: string,
|
||||
enabled: boolean,
|
||||
|
||||
@ -7,10 +7,14 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createMCPServers,
|
||||
deleteMCPServer,
|
||||
loadMCPConfig,
|
||||
MCPConfigRequestError,
|
||||
updateMCPServer,
|
||||
updateMCPServerState,
|
||||
} from "./api";
|
||||
import type { MCPServerConfig } from "./types";
|
||||
|
||||
export function useMCPConfig() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@ -42,3 +46,42 @@ export function useEnableMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(getEnableMCPServerMutationOptions(queryClient));
|
||||
}
|
||||
|
||||
export type MCPServerMutationVariables =
|
||||
| {
|
||||
operation: "create";
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
}
|
||||
| {
|
||||
operation: "update";
|
||||
serverName: string;
|
||||
server: MCPServerConfig;
|
||||
}
|
||||
| {
|
||||
operation: "delete";
|
||||
serverName: string;
|
||||
};
|
||||
|
||||
export function getMCPServerMutationOptions(queryClient: QueryClient) {
|
||||
return {
|
||||
mutationFn: (variables: MCPServerMutationVariables) => {
|
||||
switch (variables.operation) {
|
||||
case "create":
|
||||
return createMCPServers(variables.servers);
|
||||
case "update":
|
||||
return updateMCPServer(variables.serverName, variables.server);
|
||||
case "delete":
|
||||
return deleteMCPServer(variables.serverName);
|
||||
}
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useMCPServerMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(getMCPServerMutationOptions(queryClient));
|
||||
}
|
||||
|
||||
105
frontend/src/core/mcp/parse.ts
Normal file
105
frontend/src/core/mcp/parse.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import type { MCPServerConfig } from "./types";
|
||||
|
||||
export type MCPServerDefinitionErrorCode =
|
||||
| "emptyDefinition"
|
||||
| "invalidJson"
|
||||
| "rootNotObject"
|
||||
| "emptyServerMap"
|
||||
| "serverConfigNotObject";
|
||||
|
||||
/** A pasted definition that is not a usable `mcpServers` map. */
|
||||
export class MCPServerDefinitionError extends Error {
|
||||
readonly code: MCPServerDefinitionErrorCode;
|
||||
readonly serverName?: string;
|
||||
|
||||
constructor(code: MCPServerDefinitionErrorCode, serverName?: string) {
|
||||
super(code);
|
||||
this.name = "MCPServerDefinitionError";
|
||||
this.code = code;
|
||||
this.serverName = serverName;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isWrappedServerMap(value: Record<string, unknown>): value is Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
mcpServers: Record<string, unknown>;
|
||||
} {
|
||||
if (!Object.hasOwn(value, "mcpServers") || !isPlainObject(value.mcpServers)) {
|
||||
return false;
|
||||
}
|
||||
const candidates = Object.values(value.mcpServers);
|
||||
return candidates.length === 0 || candidates.every(isPlainObject);
|
||||
}
|
||||
|
||||
/** Serialize one existing server into the same copy-paste format the parser accepts. */
|
||||
export function formatMCPServerDefinition(
|
||||
name: string,
|
||||
config: MCPServerConfig,
|
||||
): string {
|
||||
return JSON.stringify({ mcpServers: { [name]: config } }, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the JSON block an MCP server publishes in its own README.
|
||||
*
|
||||
* Both the wrapped form (`{"mcpServers": {...}}`, what servers document and
|
||||
* what `extensions_config.json` stores) and a bare name-to-config map are
|
||||
* accepted, so a copied snippet works either way.
|
||||
*
|
||||
* Only the shape needed to merge the entry into the config map is checked
|
||||
* here; transport, command allowlist, and argument screening are enforced by
|
||||
* the Gateway, which is the boundary that has to hold regardless of client.
|
||||
*/
|
||||
export function parseMCPServerDefinition(
|
||||
input: string,
|
||||
): Record<string, MCPServerConfig> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new MCPServerDefinitionError("emptyDefinition");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new MCPServerDefinitionError("invalidJson");
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new MCPServerDefinitionError("rootNotObject");
|
||||
}
|
||||
|
||||
// A bare server is allowed to be named `mcpServers`. Treat that key as the
|
||||
// wrapper only when its value itself looks like a name-to-config map.
|
||||
const servers = isWrappedServerMap(parsed) ? parsed.mcpServers : parsed;
|
||||
|
||||
const entries = Object.entries(servers);
|
||||
if (entries.length === 0) {
|
||||
throw new MCPServerDefinitionError("emptyServerMap");
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
entries.map(([name, config]) => {
|
||||
if (!isPlainObject(config)) {
|
||||
throw new MCPServerDefinitionError("serverConfigNotObject", name);
|
||||
}
|
||||
// Servers are enabled on add: a definition the operator just pasted is
|
||||
// one they want running, and an entry that silently lands disabled reads
|
||||
// as a failed add. An explicit `enabled` in the snippet still wins.
|
||||
return [
|
||||
name,
|
||||
{
|
||||
enabled: true,
|
||||
description: "",
|
||||
...config,
|
||||
} as MCPServerConfig,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
}
|
||||
88
frontend/tests/e2e/mcp-settings.spec.ts
Normal file
88
frontend/tests/e2e/mcp-settings.spec.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test.describe("MCP server settings", () => {
|
||||
test("edits one server without dropping advanced fields or siblings", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
let servers = {
|
||||
local: {
|
||||
enabled: true,
|
||||
description: "Local tools",
|
||||
command: "uvx",
|
||||
args: ["local-tools"],
|
||||
},
|
||||
remote: {
|
||||
enabled: false,
|
||||
description: "Remote tools",
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { "X-API-Key": "***" },
|
||||
routing: { mode: "prefer" },
|
||||
},
|
||||
};
|
||||
let submittedUpdate:
|
||||
| { server_name: string; server: (typeof servers)["remote"] }
|
||||
| undefined;
|
||||
|
||||
await page.route("**/api/mcp/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) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
submittedUpdate = route
|
||||
.request()
|
||||
.postDataJSON() as typeof submittedUpdate;
|
||||
servers = {
|
||||
...servers,
|
||||
[submittedUpdate!.server_name]: submittedUpdate!.server,
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ mcp_servers: servers }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new?settings=tools");
|
||||
|
||||
const settingsDialog = page.getByRole("dialog", { name: "Settings" });
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
await settingsDialog.getByRole("button", { name: "Edit remote" }).click();
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Edit MCP server" });
|
||||
const definitionBox = editor.getByRole("textbox");
|
||||
const definition = JSON.parse(await definitionBox.inputValue()) as {
|
||||
mcpServers: typeof servers;
|
||||
};
|
||||
definition.mcpServers.remote.description = "Updated remote tools";
|
||||
await definitionBox.fill(JSON.stringify(definition));
|
||||
await editor.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(editor).toBeHidden();
|
||||
await expect(
|
||||
settingsDialog.getByText("Updated remote tools"),
|
||||
).toBeVisible();
|
||||
expect(submittedUpdate).toEqual({
|
||||
server_name: "remote",
|
||||
server: {
|
||||
enabled: false,
|
||||
description: "Updated remote tools",
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { "X-API-Key": "***" },
|
||||
routing: { mode: "prefer" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -6,18 +6,57 @@ import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-
|
||||
const mcpMockState = rs.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: rs.fn(),
|
||||
updateIsPending: false,
|
||||
updateMutate: rs.fn(),
|
||||
servers: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
// A server carrying config this page never renders: it must survive a write
|
||||
// that only meant to add or remove some other entry.
|
||||
const DURABLE_TASK_SERVER = {
|
||||
enabled: true,
|
||||
description: "Remote tools",
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
task_toolsets: [{ submit: "run", status: "poll" }],
|
||||
routing: { mode: "prefer", priority: 50 },
|
||||
headers: { "X-API-Key": "***" },
|
||||
};
|
||||
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
common: { loading: "Loading" },
|
||||
common: {
|
||||
loading: "Loading",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
},
|
||||
settings: {
|
||||
tools: {
|
||||
title: "Tools",
|
||||
description: "Manage MCP tools",
|
||||
adminRequired: "Admin required",
|
||||
empty: "No tools",
|
||||
addServer: "Add server",
|
||||
addServerDescription: "Paste the definition",
|
||||
addServerPlaceholder: "{}",
|
||||
serverDefinitionLabel: "MCP server JSON definition",
|
||||
definitionEmpty: "Paste a definition",
|
||||
definitionInvalidJson: "Enter valid JSON",
|
||||
definitionRootNotObject: "Enter a JSON object",
|
||||
definitionNoServers: "No server found",
|
||||
definitionServerNotObject:
|
||||
'The configuration for server "{name}" must be an object',
|
||||
editServer: "Edit MCP server",
|
||||
editServerDescription: 'Edit "{name}"',
|
||||
editSingleServer: "Edit exactly one server",
|
||||
editServerNameMismatch: 'Keep the name "{name}"',
|
||||
serverAlreadyExists: 'Server "{name}" already exists',
|
||||
removeServer: "Remove MCP server",
|
||||
removeServerDescription: 'Remove "{name}"?',
|
||||
unnamedServer: "(empty name)",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -26,12 +65,7 @@ rs.mock("@/core/i18n/hooks", () => ({
|
||||
|
||||
rs.mock("@/core/mcp/hooks", () => ({
|
||||
useMCPConfig: () => ({
|
||||
config: {
|
||||
mcp_servers: {
|
||||
github: { enabled: true, description: "GitHub tools" },
|
||||
remote: { enabled: false, description: "Remote tools" },
|
||||
},
|
||||
},
|
||||
config: { mcp_servers: mcpMockState.servers },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
@ -39,20 +73,61 @@ rs.mock("@/core/mcp/hooks", () => ({
|
||||
isPending: mcpMockState.isPending,
|
||||
mutate: mcpMockState.mutate,
|
||||
}),
|
||||
useMCPServerMutation: () => ({
|
||||
isPending: mcpMockState.updateIsPending,
|
||||
mutate: mcpMockState.updateMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
rs.mock("@/env", () => ({
|
||||
env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" },
|
||||
}));
|
||||
|
||||
function setServers(servers: Record<string, unknown>) {
|
||||
mcpMockState.servers = servers;
|
||||
}
|
||||
|
||||
function twoServers() {
|
||||
setServers({
|
||||
github: { enabled: true, description: "GitHub tools" },
|
||||
remote: { ...DURABLE_TASK_SERVER, enabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
/** The targeted mutation variables handed to the last call. */
|
||||
function lastMutation() {
|
||||
const call = mcpMockState.updateMutate.mock.calls.at(-1);
|
||||
return call?.[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add server" }));
|
||||
}
|
||||
|
||||
function openEditDialog(name: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: `Edit ${name}` }));
|
||||
}
|
||||
|
||||
function definitionTextbox(): HTMLTextAreaElement {
|
||||
const element = screen.getByRole("textbox");
|
||||
if (!(element instanceof HTMLTextAreaElement)) {
|
||||
throw new TypeError("MCP definition editor must be a textarea");
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mcpMockState.isPending = false;
|
||||
mcpMockState.updateIsPending = false;
|
||||
mcpMockState.mutate.mockReset();
|
||||
mcpMockState.updateMutate.mockReset();
|
||||
mcpMockState.servers = {};
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ToolSettingsPage MCP switches", () => {
|
||||
it("disables every switch while a targeted update is pending", () => {
|
||||
twoServers();
|
||||
mcpMockState.isPending = true;
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
@ -65,6 +140,8 @@ describe("ToolSettingsPage MCP switches", () => {
|
||||
});
|
||||
|
||||
it("submits only the selected server state when idle", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
|
||||
const switches = screen.getAllByRole("switch");
|
||||
@ -80,3 +157,204 @@ describe("ToolSettingsPage MCP switches", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolSettingsPage add server", () => {
|
||||
it("submits only the pasted servers to the atomic create endpoint", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openAddDialog();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: {
|
||||
value: '{"mcpServers": {"added": {"command": "npx", "args": []}}}',
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
operation: "create",
|
||||
servers: {
|
||||
added: { command: "npx", args: [], enabled: true, description: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit stale sibling configurations while adding", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openAddDialog();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: '{"added": {"command": "uvx"}}' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
operation: "create",
|
||||
servers: {
|
||||
added: { command: "uvx", enabled: true, description: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a malformed definition without writing", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openAddDialog();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "{not json" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert").textContent).toBe("Enter valid JSON");
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "MCP server JSON definition" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers the add action when no server is configured yet", () => {
|
||||
setServers({});
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
|
||||
expect(screen.getByText("No tools")).toBeDefined();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Add server" })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an existing name instead of silently replacing it", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openAddDialog();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: '{"github": {"command": "uvx"}}' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Server "github" already exists')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolSettingsPage edit server", () => {
|
||||
it("prefills the complete server definition", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openEditDialog("remote");
|
||||
|
||||
const definition = JSON.parse(definitionTextbox().value) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(definition.mcpServers)).toEqual(["remote"]);
|
||||
expect(definition.mcpServers.remote).toEqual({
|
||||
...DURABLE_TASK_SERVER,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates only one server while preserving all of its hidden fields", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openEditDialog("remote");
|
||||
const textbox = definitionTextbox();
|
||||
const definition = JSON.parse(textbox.value) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
definition.mcpServers.remote!.description = "Updated remote tools";
|
||||
fireEvent.change(textbox, {
|
||||
target: { value: JSON.stringify(definition) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
operation: "update",
|
||||
serverName: "remote",
|
||||
server: {
|
||||
...DURABLE_TASK_SERVER,
|
||||
enabled: false,
|
||||
description: "Updated remote tools",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects renaming through the edit dialog", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openEditDialog("github");
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: {
|
||||
value: '{"mcpServers": {"renamed": {"command": "npx"}}}',
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Keep the name "github"')).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects editing multiple servers at once", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
openEditDialog("github");
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: {
|
||||
value:
|
||||
'{"mcpServers": {"github": {"command": "npx"}, "extra": {"command": "uvx"}}}',
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Edit exactly one server")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolSettingsPage remove server", () => {
|
||||
it("submits only the selected server name", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
operation: "delete",
|
||||
serverName: "github",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write when the confirmation is dismissed", () => {
|
||||
twoServers();
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a configured server whose name is empty", () => {
|
||||
setServers({ "": { enabled: false, description: "Legacy server" } });
|
||||
|
||||
render(<ToolSettingsPage />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Delete (empty name)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
operation: "delete",
|
||||
serverName: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,7 +14,10 @@ rs.mock("sonner", () => ({
|
||||
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { MCPConfigRequestError, loadMCPConfig } from "@/core/mcp/api";
|
||||
import { getEnableMCPServerMutationOptions } from "@/core/mcp/hooks";
|
||||
import {
|
||||
getEnableMCPServerMutationOptions,
|
||||
getMCPServerMutationOptions,
|
||||
} from "@/core/mcp/hooks";
|
||||
|
||||
const mockedFetch = rs.mocked(fetch);
|
||||
const mockedToastError = rs.mocked(toast.error);
|
||||
@ -139,3 +142,116 @@ describe("MCP server state mutation", () => {
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP server CRUD mutation", () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToastError.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
variables: {
|
||||
operation: "create" as const,
|
||||
servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
path: "/api/mcp/config/servers",
|
||||
method: "POST",
|
||||
body: {
|
||||
mcp_servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "update" as const,
|
||||
serverName: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
path: "/api/mcp/config/server",
|
||||
method: "PUT",
|
||||
body: {
|
||||
server_name: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "delete" as const,
|
||||
serverName: "team/tools",
|
||||
},
|
||||
path: "/api/mcp/config/servers/team%2Ftools",
|
||||
method: "DELETE",
|
||||
body: undefined,
|
||||
},
|
||||
])(
|
||||
"sends a targeted $method request and invalidates the config",
|
||||
async ({ variables, path, method, body }) => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs
|
||||
.spyOn(client, "invalidateQueries")
|
||||
.mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
|
||||
await mutation.execute(variables);
|
||||
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url.endsWith(path)).toBe(true);
|
||||
expect(request.method).toBe(method);
|
||||
if (body === undefined) {
|
||||
expect(request.body).toBeUndefined();
|
||||
} else {
|
||||
expect(JSON.parse(request.body as string)).toEqual(body);
|
||||
}
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["mcpConfig"],
|
||||
});
|
||||
expect(mockedToastError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an empty server name addressable without a DELETE body", async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
rs.spyOn(client, "invalidateQueries").mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
|
||||
await mutation.execute({ operation: "delete", serverName: "" });
|
||||
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url.endsWith("/api/mcp/config/servers/")).toBe(true);
|
||||
expect(request.method).toBe("DELETE");
|
||||
expect(request.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces the Gateway rejection detail without invalidating", async () => {
|
||||
const detail =
|
||||
"MCP server 'evil' uses disallowed stdio command 'bash'. Allowed commands: npx, uvx.";
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
|
||||
await expect(
|
||||
mutation.execute({
|
||||
operation: "create",
|
||||
servers: { evil: { enabled: true, description: "" } },
|
||||
}),
|
||||
).rejects.toThrow(detail);
|
||||
|
||||
expect(mockedToastError).toHaveBeenCalledWith(detail);
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
154
frontend/tests/unit/core/mcp/parse.test.ts
Normal file
154
frontend/tests/unit/core/mcp/parse.test.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
formatMCPServerDefinition,
|
||||
MCPServerDefinitionError,
|
||||
type MCPServerDefinitionErrorCode,
|
||||
parseMCPServerDefinition,
|
||||
} from "@/core/mcp/parse";
|
||||
|
||||
function expectDefinitionError(
|
||||
input: string,
|
||||
code: MCPServerDefinitionErrorCode,
|
||||
) {
|
||||
try {
|
||||
parseMCPServerDefinition(input);
|
||||
throw new Error("Expected the definition to be rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MCPServerDefinitionError);
|
||||
expect((error as MCPServerDefinitionError).code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatMCPServerDefinition", () => {
|
||||
it("serializes one complete server into the wrapped edit format", () => {
|
||||
const definition = formatMCPServerDefinition("remote", {
|
||||
enabled: false,
|
||||
description: "Remote tools",
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { Authorization: "***" },
|
||||
});
|
||||
|
||||
expect(JSON.parse(definition)).toEqual({
|
||||
mcpServers: {
|
||||
remote: {
|
||||
enabled: false,
|
||||
description: "Remote tools",
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { Authorization: "***" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMCPServerDefinition", () => {
|
||||
it("accepts the wrapped form servers publish in their README", () => {
|
||||
const parsed = parseMCPServerDefinition(`{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"]
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(Object.keys(parsed)).toEqual(["github"]);
|
||||
expect(parsed.github).toMatchObject({
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a bare server map", () => {
|
||||
const parsed = parseMCPServerDefinition(
|
||||
`{"remote": {"type": "http", "url": "https://example.test/mcp"}}`,
|
||||
);
|
||||
|
||||
expect(Object.keys(parsed)).toEqual(["remote"]);
|
||||
expect(parsed.remote).toMatchObject({
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a bare server named mcpServers", () => {
|
||||
const parsed = parseMCPServerDefinition(
|
||||
`{"mcpServers": {"command": "npx"}}`,
|
||||
);
|
||||
|
||||
expect(parsed.mcpServers).toMatchObject({
|
||||
command: "npx",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an existing empty server name", () => {
|
||||
const parsed = parseMCPServerDefinition(`{"": {"command": "npx"}}`);
|
||||
|
||||
expect(parsed[""]).toMatchObject({ command: "npx", enabled: true });
|
||||
});
|
||||
|
||||
it("enables a pasted server by default", () => {
|
||||
const parsed = parseMCPServerDefinition(`{"a": {"command": "uvx"}}`);
|
||||
|
||||
expect(parsed.a?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an explicit enabled flag from the definition", () => {
|
||||
const parsed = parseMCPServerDefinition(
|
||||
`{"a": {"command": "uvx", "enabled": false}}`,
|
||||
);
|
||||
|
||||
expect(parsed.a?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves fields this page never renders", () => {
|
||||
const parsed = parseMCPServerDefinition(`{
|
||||
"a": {
|
||||
"command": "uvx",
|
||||
"task_toolsets": [{"submit": "run"}],
|
||||
"routing": {"mode": "prefer"}
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(parsed.a).toMatchObject({
|
||||
task_toolsets: [{ submit: "run" }],
|
||||
routing: { mode: "prefer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts multiple servers in one definition", () => {
|
||||
const parsed = parseMCPServerDefinition(
|
||||
`{"mcpServers": {"a": {"command": "npx"}, "b": {"command": "uvx"}}}`,
|
||||
);
|
||||
|
||||
expect(Object.keys(parsed).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("rejects blank input", () => {
|
||||
expectDefinitionError(" ", "emptyDefinition");
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expectDefinitionError("{not json", "invalidJson");
|
||||
});
|
||||
|
||||
it("rejects a non-object payload", () => {
|
||||
expectDefinitionError("[1, 2]", "rootNotObject");
|
||||
});
|
||||
|
||||
it("rejects an empty server map", () => {
|
||||
expectDefinitionError(`{"mcpServers": {}}`, "emptyServerMap");
|
||||
});
|
||||
|
||||
it("rejects a server entry that is not an object", () => {
|
||||
expectDefinitionError(`{"a": "npx"}`, "serverConfigNotObject");
|
||||
});
|
||||
|
||||
it("rejects a non-object mcpServers value", () => {
|
||||
expectDefinitionError(`{"mcpServers": []}`, "serverConfigNotObject");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user