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:
Aari 2026-09-01 23:24:49 +08:00 committed by GitHub
parent 91c7ed4cf5
commit 340bff1107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3464 additions and 162 deletions

View File

@ -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 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. 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. 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. 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. 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. 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.

View File

@ -46,7 +46,7 @@ owner-scoped assistant version selection remains enabled.
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details | | **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 | | **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 | | **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 | | **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 | | **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. | | **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. |

View File

@ -4,10 +4,10 @@ import logging
import os import os
import re import re
from pathlib import Path 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 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 app.gateway.deps import require_admin_user
from deerflow.config.extensions_config import ( from deerflow.config.extensions_config import (
@ -22,6 +22,7 @@ from deerflow.config.extensions_config import (
normalize_mcp_transport_alias, normalize_mcp_transport_alias,
reload_extensions_config, reload_extensions_config,
) )
from deerflow.config.runtime_paths import project_root
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT
from deerflow.mcp.cache import reset_mcp_tools_cache 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") 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") 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") 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): class McpServerConfigResponse(BaseModel):
@ -506,12 +510,24 @@ class McpServerStateUpdateRequest(BaseModel):
server_name: str = Field( server_name: str = Field(
..., ...,
min_length=1,
description="Name of the MCP server to update", description="Name of the MCP server to update",
) )
enabled: bool = Field(..., description="Whether the MCP server is enabled") 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): class McpCacheResetResponse(BaseModel):
"""Response model for resetting the MCP tools cache.""" """Response model for resetting the MCP tools cache."""
@ -544,6 +560,66 @@ def _mask_sensitive_extra_value(value: Any) -> Any:
return value 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: 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 incoming_value == _MASKED_VALUE and _is_sensitive_extra_key(key):
if existing_present: if existing_present:
@ -565,8 +641,15 @@ def _merge_extra_value_preserving_masked(key: str, incoming_value: Any, existing
) )
return merged return merged
if isinstance(incoming_value, list) and isinstance(existing_value, list) and len(incoming_value) == len(existing_value): if isinstance(incoming_value, list) and isinstance(existing_value, list):
return [_merge_extra_value_preserving_masked(key, nested_value, existing_value[index], existing_present=True) for index, nested_value in enumerate(incoming_value)] 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 return incoming_value
@ -675,37 +758,43 @@ def _arbitrary_exec_arg(args: list[str], *, command: str) -> str | None:
return 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. """Validate API-submitted MCP config before it is persisted.
Local config files can still express arbitrary advanced setups, but the Local config files can still express arbitrary advanced setups, but the
HTTP API is an untrusted boundary. Restricting stdio commands here reduces HTTP API is an untrusted boundary. Restricting stdio commands here reduces
the blast radius of a compromised authenticated browser session. the blast radius of a compromised authenticated browser session.
The command name alone is not a meaningful restriction, so the launcher's Command shape and code-injecting environment variables are invalid at the
``args`` and ``env`` are screened for the flags and variables that turn an API boundary even while a server remains disabled. The allowlist and its
allowlisted binary into an arbitrary code evaluator. 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(): for name, server in request.mcp_servers.items():
transport_type = (server.type or "stdio").lower() transport_type = (server.type or "stdio").lower()
if transport_type != "stdio": if transport_type != "stdio":
continue continue
command_name = _stdio_command_name(server.command, server_name=name) command_name = _stdio_command_name(server.command, server_name=name)
if command_name not in allowed_commands: if enforce_execution_policy:
allowed = ", ".join(sorted(allowed_commands)) or "<none>" if command_name not in allowed_commands:
raise HTTPException( allowed = ", ".join(sorted(allowed_commands)) or "<none>"
status_code=status.HTTP_400_BAD_REQUEST, raise HTTPException(
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."), 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) exec_flag = _arbitrary_exec_arg(server.args, command=command_name)
if exec_flag is not None: if exec_flag is not None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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."), 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: for env_name in server.env:
if env_name.strip().upper() in _CODE_INJECTING_ENV_VARS: 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_headers = {k: _MASKED_VALUE for k in server.headers}
masked_oauth = None masked_oauth = None
if server.oauth is not 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( masked_oauth = server.oauth.model_copy(
update={ update={
"client_secret": None, "client_secret": None,
"refresh_token": None, "refresh_token": None,
"extra_token_params": masked_extra_token_params,
**masked_oauth_extra,
} }
) )
masked_user_auth = None 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. # 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_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_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()} 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( return server.model_copy(
update={ update={
@ -755,6 +855,7 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
"oauth": masked_oauth, "oauth": masked_oauth,
"user_auth": masked_user_auth, "user_auth": masked_user_auth,
"headers_from_context": masked_headers_from_context, "headers_from_context": masked_headers_from_context,
"tools": masked_tools,
**masked_extra, **masked_extra,
} }
) )
@ -763,6 +864,8 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
def _merge_preserving_secrets( def _merge_preserving_secrets(
incoming: McpServerConfigResponse, incoming: McpServerConfigResponse,
existing: McpServerConfigResponse, existing: McpServerConfigResponse,
*,
preserve_omitted_fields: bool = True,
) -> McpServerConfigResponse: ) -> McpServerConfigResponse:
"""Merge incoming config with existing, preserving secrets masked by GET. """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 so masked GET responses can be safely round-tripped. To explicitly clear
a stored secret, clients may send an empty string, which is converted a stored secret, clients may send an empty string, which is converted
to ``None`` before persisting. 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 = {} merged_env = {}
for k, v in incoming.env.items(): for k, v in incoming.env.items():
@ -806,93 +914,163 @@ def _merge_preserving_secrets(
merged_headers[k] = v merged_headers[k] = v
merged_oauth = incoming.oauth merged_oauth = incoming.oauth
if incoming.oauth is not None and existing.oauth is not None: if incoming.oauth is not None:
# None = preserve (masked round-trip), "" = explicitly clear, else = new value incoming_oauth = incoming.oauth
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) base_oauth = existing.oauth
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) base_extra_token_params = base_oauth.extra_token_params if base_oauth is not None else {}
merged_oauth = incoming.oauth.model_copy( 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={ update={
"client_secret": merged_client_secret, "client_secret": merged_client_secret,
"refresh_token": merged_refresh_token, "refresh_token": merged_refresh_token,
"extra_token_params": merged_extra_token_params,
**merged_oauth_extra,
} }
) )
merged_user_auth = incoming.user_auth merged_user_auth = incoming.user_auth
if incoming.user_auth is not None: 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 incoming_ua = incoming.user_auth
base = existing.user_auth base = existing.user_auth
set_fields = incoming_ua.model_fields_set 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 {} 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(): 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, key,
value, value,
base_extra.get(key), base_extra.get(key),
existing_present=key in base_extra, existing_present=key in base_extra,
) )
if "users" in set_fields:
# An explicitly sent map replaces the stored one (so a full existing_users = base.users if base is not None else {}
# round-trip can remove a user), with masked values swapped back merged_users = {}
# for the stored credentials. for k, v in incoming_ua.users.items():
existing_users = base.users if base is not None else {} if v == _MASKED_VALUE:
merged_users = {} if k in existing_users:
for k, v in incoming_ua.users.items(): merged_users[k] = existing_users[k]
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.",
)
else: else:
merged_users[k] = v raise HTTPException(
effective["users"] = merged_users status_code=400,
merged_user_auth = McpUserScopedAuthConfigResponse(**effective) 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 merged_context_headers = incoming.headers_from_context
if incoming.headers_from_context is not None: 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 incoming_ch = incoming.headers_from_context
base_ch = existing.headers_from_context base_ch = existing.headers_from_context
set_fields = incoming_ch.model_fields_set 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 # 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 # PUT must swap masked sentinel values back for the stored ones — the
# same contract user_auth extras and server-level extras get. # 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 {} 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(): 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, key,
value, value,
base_ch_extra.get(key), base_ch_extra.get(key),
existing_present=key in base_ch_extra, 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 = { update = {
"env": merged_env, "env": merged_env,
@ -900,14 +1078,15 @@ def _merge_preserving_secrets(
"oauth": merged_oauth, "oauth": merged_oauth,
"user_auth": merged_user_auth, "user_auth": merged_user_auth,
"headers_from_context": merged_context_headers, "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 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 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 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 update["tools"] = existing.tools
incoming_extra = incoming.model_extra or {} incoming_extra = incoming.model_extra or {}
existing_extra = existing.model_extra or {} existing_extra = existing.model_extra or {}
@ -918,10 +1097,13 @@ def _merge_preserving_secrets(
existing_extra.get(key), existing_extra.get(key),
existing_present=key in existing_extra, existing_present=key in existing_extra,
) )
for key, value in (existing.model_extra or {}).items(): if preserve_omitted_fields:
if key not in (incoming.model_extra or {}): for key, value in (existing.model_extra or {}).items():
update[key] = value if key not in (incoming.model_extra or {}):
return incoming.model_copy(update=update) update[key] = value
merged = incoming.model_copy(update=update)
_ensure_no_masked_secrets(merged)
return merged
@router.get( @router.get(
@ -953,12 +1135,42 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
""" """
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = get_extensions_config() raw_servers = await asyncio.to_thread(_load_raw_mcp_server_responses)
servers = {name: _mask_server_config(server) for name, server in raw_servers.items()}
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in config.mcp_servers.items()}
return McpConfigResponse(mcp_servers=servers) 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: def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
"""Worker-thread body for :func:`update_mcp_configuration`. """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. # same sidecar path for the complete read-modify-write cycle.
config_path = ExtensionsConfig.resolve_config_path() config_path = ExtensionsConfig.resolve_config_path()
if config_path is None: 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}") 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): with extensions_config_write_lock, extensions_config_file_lock(config_path):
# Load raw (un-resolved) JSON from disk to use as the merge source. # Load raw (un-resolved) JSON from disk to use as the merge source.
# This preserves $VAR placeholders in env values and top-level keys # This preserves $VAR placeholders in env values and top-level keys
# like mcpInterceptors that would otherwise be lost. # 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_other_keys: dict = {}
raw_skills: dict[str, dict] | None = None raw_skills: dict[str, dict] | None = None
if config_path is not None and config_path.exists(): if isinstance(raw_data.get("skills"), dict):
with open(config_path, encoding="utf-8") as f: raw_skills = raw_data["skills"]
raw_data = json.load(f) # Preserve any top-level keys beyond mcpServers/skills
raw_servers = raw_data.get("mcpServers", {}) for key, value in raw_data.items():
if isinstance(raw_data.get("skills"), dict): if key not in ("mcpServers", "skills"):
raw_skills = raw_data["skills"] raw_other_keys[key] = value
# 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 # Merge incoming server configs with raw on-disk secrets
merged_servers: dict[str, McpServerConfigResponse] = {} merged_servers: dict[str, McpServerConfigResponse] = {}
for name, incoming in body.mcp_servers.items(): for name, incoming in body.mcp_servers.items():
raw_server = raw_servers.get(name) raw_server = raw_servers.get(name)
if raw_server is not None: if raw_server is not None:
merged_servers[name] = _merge_preserving_secrets( merged = _merge_preserving_secrets(
incoming, incoming,
McpServerConfigResponse(**raw_server), _mcp_server_response_from_raw(name, raw_server),
) )
else: 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 # Build config data preserving all top-level keys from the original file
config_data = dict(raw_other_keys) 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()} raw_skills = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
config_data["skills"] = raw_skills config_data["skills"] = raw_skills
_validate_extensions_config_candidate(config_data)
atomic_write_extensions_config(config_path, config_data) atomic_write_extensions_config(config_path, config_data)
logger.info(f"MCP configuration updated and saved to: {config_path}") 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 # Reload the Gateway configuration and update the global cache. The
# agent runtime lives in Gateway, so this keeps API reads and tool # agent runtime lives in Gateway, so this keeps API reads and tool
# execution aligned after extensions_config.json changes. # execution aligned after extensions_config.json changes.
reloaded_config = reload_extensions_config() reload_extensions_config()
return reloaded_config.mcp_servers return _mcp_server_responses_from_raw(config_data)
def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict: 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", detail=f"MCP server '{body.server_name}' not found",
) )
with open(config_path, encoding="utf-8") as f: raw_data = _load_raw_extensions_config(config_path, create=False)
raw_data = json.load(f) raw_servers = _raw_mcp_servers(raw_data)
if body.server_name not in raw_servers:
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):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"MCP server '{body.server_name}' 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: if body.enabled:
target_server = McpServerConfigResponse(**raw_server)
_validate_mcp_update_request( _validate_mcp_update_request(
McpConfigUpdateRequest( McpConfigUpdateRequest(
mcp_servers={body.server_name: target_server}, 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 raw_server["enabled"] = body.enabled
_validate_extensions_config_candidate(raw_data)
atomic_write_extensions_config(config_path, 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) logger.info("MCP server %s enabled state updated to %s", body.server_name, body.enabled)
reloaded_config = reload_extensions_config() reload_extensions_config()
return reloaded_config.mcp_servers 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( @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)}") 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( @router.patch(
"/mcp/config", "/mcp/config",
response_model=McpConfigResponse, response_model=McpConfigResponse,

View File

@ -437,7 +437,10 @@ GET /api/mcp/config
``` ```
Requires an authenticated admin session. Sensitive env/header/OAuth secret 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:** **Response:**
```json ```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 An unknown `server_name` returns `404`; attempting to enable a server with a
disallowed `stdio` command returns `400`. 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 #### Reset MCP Tools Cache
Clear cached MCP tools and persistent MCP sessions process-wide. This affects Clear cached MCP tools and persistent MCP sessions process-wide. This affects

View File

@ -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 The GET handler resolves the extensions config path and reads raw JSON. PUT
existence, read raw JSON, atomically write it, and reload it all blocking and PATCH also atomically write and reload it. All of that is blocking
filesystem IO. They offload the whole read-modify-write via filesystem IO, so the handlers offload the read or whole read-modify-write via
``asyncio.to_thread``; if either regresses back onto the event loop, the strict ``asyncio.to_thread``. If one regresses back onto the event loop, the strict
Blockbuster gate raises ``BlockingError`` and this test fails. 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 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, McpConfigUpdateRequest,
McpServerConfigResponse, McpServerConfigResponse,
McpServerStateUpdateRequest, McpServerStateUpdateRequest,
get_mcp_configuration,
update_mcp_configuration, update_mcp_configuration,
update_mcp_server_state, update_mcp_server_state,
) )
@ -33,6 +34,27 @@ from app.gateway.routers.mcp import (
pytestmark = pytest.mark.asyncio 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: async def test_update_mcp_configuration_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "extensions_config.json" config_path = tmp_path / "extensions_config.json"
# resolve_config_path() requires the env-pointed file to exist; seed a minimal one. # resolve_config_path() requires the env-pointed file to exist; seed a minimal one.

File diff suppressed because it is too large Load Diff

View File

@ -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" 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(): 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.""" """A sentinel with nothing stored behind it must not be written to disk."""
from fastapi import HTTPException from fastapi import HTTPException

View File

@ -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 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(): def test_user_auth_extra_keys_survive_parse_mask_and_merge():
from app.gateway.routers.mcp import ( from app.gateway.routers.mcp import (
McpServerConfigResponse, 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" 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): def test_stdio_server_user_auth_is_skipped_with_warning(caplog):
import logging import logging

View File

@ -18,6 +18,11 @@
mutation, disables switches until that mutation's success refetch completes, mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates displays the backend error `detail` through a toast, and invalidates
`["mcpConfig"]` only after success. `["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 Current-chat MCP background tasks use `core/background-tasks`: the header
trigger is hidden for new/mock/static-demo threads and unless `/api/features` 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 reports the startup-scoped `mcp_tasks` capability; the list query is disabled

View File

@ -1,5 +1,17 @@
"use client"; "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 { import {
Item, Item,
ItemActions, ItemActions,
@ -8,9 +20,19 @@ import {
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { MCPConfigRequestError } from "@/core/mcp/api"; 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 type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env"; import { env } from "@/env";
@ -48,41 +70,267 @@ function MCPServerList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { isPending, mutate: enableMCPServer } = useEnableMCPServer(); const { isPending, mutate: enableMCPServer } = useEnableMCPServer();
const entries = Object.entries(servers ?? {}); const { isPending: isWriting, mutate: mutateServer } = useMCPServerMutation();
if (entries.length === 0) { const [editor, setEditor] = useState<
return ( { mode: "add" } | { mode: "edit"; name: string } | null
<div className="text-muted-foreground text-sm"> >(null);
{t.settings.tools.empty} const [definition, setDefinition] = useState("");
</div> 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 ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{entries.map(([name, config]) => ( <div className="flex justify-end">
<Item className="w-full" variant="outline" key={name}> <Button
<ItemContent> size="sm"
<ItemTitle> variant="outline"
<div className="flex items-center gap-2"> disabled={readOnly || isMutating}
<div>{name}</div> onClick={openAddEditor}
</div> >
</ItemTitle> {t.settings.tools.addServer}
<ItemDescription className="line-clamp-4"> </Button>
{config.description} </div>
</ItemDescription>
</ItemContent> {entries.length === 0 ? (
<ItemActions> <div className="text-muted-foreground text-sm">
<Switch {t.settings.tools.empty}
checked={config.enabled} </div>
disabled={ ) : (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isPending 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 }) {isWriting ? t.common.loading : t.common.delete}
} </Button>
/> </DialogFooter>
</ItemActions> </DialogContent>
</Item> </Dialog>
))}
</div> </div>
); );
} }

View File

@ -952,6 +952,37 @@ export const enUS: Translations = {
description: "Manage the configuration and enabled status of MCP tools.", description: "Manage the configuration and enabled status of MCP tools.",
adminRequired: "Admin privileges are required to manage MCP tools.", adminRequired: "Admin privileges are required to manage MCP tools.",
empty: "No MCP tools configured.", 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: { subagents: {
title: "Subagents", title: "Subagents",

View File

@ -812,6 +812,23 @@ export interface Translations {
description: string; description: string;
adminRequired: string; adminRequired: string;
empty: 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: { subagents: {
title: string; title: string;

View File

@ -915,6 +915,33 @@ export const zhCN: Translations = {
description: "管理 MCP 工具的配置和启用状态。", description: "管理 MCP 工具的配置和启用状态。",
adminRequired: "需要管理员权限才能管理 MCP 工具。", adminRequired: "需要管理员权限才能管理 MCP 工具。",
empty: "暂无 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: { subagents: {
title: "子智能体", title: "子智能体",

View File

@ -1,7 +1,7 @@
import { fetch } from "@/core/api/fetcher"; import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import type { MCPConfig } from "./types"; import type { MCPConfig, MCPServerConfig } from "./types";
export class MCPConfigRequestError extends Error { export class MCPConfigRequestError extends Error {
readonly status: number; readonly status: number;
@ -53,6 +53,56 @@ export async function updateMCPConfig(config: MCPConfig) {
return response.json(); 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( export async function updateMCPServerState(
serverName: string, serverName: string,
enabled: boolean, enabled: boolean,

View File

@ -7,10 +7,14 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createMCPServers,
deleteMCPServer,
loadMCPConfig, loadMCPConfig,
MCPConfigRequestError, MCPConfigRequestError,
updateMCPServer,
updateMCPServerState, updateMCPServerState,
} from "./api"; } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() { export function useMCPConfig() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@ -42,3 +46,42 @@ export function useEnableMCPServer() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation(getEnableMCPServerMutationOptions(queryClient)); 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));
}

View 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;
}),
);
}

View 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" },
},
});
});
});

View File

@ -6,18 +6,57 @@ import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-
const mcpMockState = rs.hoisted(() => ({ const mcpMockState = rs.hoisted(() => ({
isPending: false, isPending: false,
mutate: rs.fn(), 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", () => ({ rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ useI18n: () => ({
t: { t: {
common: { loading: "Loading" }, common: {
loading: "Loading",
cancel: "Cancel",
save: "Save",
delete: "Delete",
edit: "Edit",
},
settings: { settings: {
tools: { tools: {
title: "Tools", title: "Tools",
description: "Manage MCP tools", description: "Manage MCP tools",
adminRequired: "Admin required", adminRequired: "Admin required",
empty: "No tools", 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", () => ({ rs.mock("@/core/mcp/hooks", () => ({
useMCPConfig: () => ({ useMCPConfig: () => ({
config: { config: { mcp_servers: mcpMockState.servers },
mcp_servers: {
github: { enabled: true, description: "GitHub tools" },
remote: { enabled: false, description: "Remote tools" },
},
},
isLoading: false, isLoading: false,
error: null, error: null,
}), }),
@ -39,20 +73,61 @@ rs.mock("@/core/mcp/hooks", () => ({
isPending: mcpMockState.isPending, isPending: mcpMockState.isPending,
mutate: mcpMockState.mutate, mutate: mcpMockState.mutate,
}), }),
useMCPServerMutation: () => ({
isPending: mcpMockState.updateIsPending,
mutate: mcpMockState.updateMutate,
}),
})); }));
rs.mock("@/env", () => ({ rs.mock("@/env", () => ({
env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, 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(() => { afterEach(() => {
mcpMockState.isPending = false; mcpMockState.isPending = false;
mcpMockState.updateIsPending = false;
mcpMockState.mutate.mockReset(); mcpMockState.mutate.mockReset();
mcpMockState.updateMutate.mockReset();
mcpMockState.servers = {};
cleanup(); cleanup();
}); });
describe("ToolSettingsPage MCP switches", () => { describe("ToolSettingsPage MCP switches", () => {
it("disables every switch while a targeted update is pending", () => { it("disables every switch while a targeted update is pending", () => {
twoServers();
mcpMockState.isPending = true; mcpMockState.isPending = true;
render(<ToolSettingsPage />); render(<ToolSettingsPage />);
@ -65,6 +140,8 @@ describe("ToolSettingsPage MCP switches", () => {
}); });
it("submits only the selected server state when idle", () => { it("submits only the selected server state when idle", () => {
twoServers();
render(<ToolSettingsPage />); render(<ToolSettingsPage />);
const switches = screen.getAllByRole("switch"); 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: "",
});
});
});

View File

@ -14,7 +14,10 @@ rs.mock("sonner", () => ({
import { fetch } from "@/core/api/fetcher"; import { fetch } from "@/core/api/fetcher";
import { MCPConfigRequestError, loadMCPConfig } from "@/core/mcp/api"; 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 mockedFetch = rs.mocked(fetch);
const mockedToastError = rs.mocked(toast.error); const mockedToastError = rs.mocked(toast.error);
@ -139,3 +142,116 @@ describe("MCP server state mutation", () => {
expect(invalidateQueries).not.toHaveBeenCalled(); 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();
});
});

View 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");
});
});