mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 00:19:14 +00:00
* 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
1596 lines
69 KiB
Python
1596 lines
69 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Literal, NamedTuple, NoReturn
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, status
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
|
|
from app.gateway.deps import require_admin_user
|
|
from deerflow.config.extensions_config import (
|
|
ExtensionsConfig,
|
|
McpRoutingConfig,
|
|
McpTaskToolsetConfig,
|
|
McpToolOverride,
|
|
atomic_write_extensions_config,
|
|
extensions_config_file_lock,
|
|
extensions_config_write_lock,
|
|
get_extensions_config,
|
|
normalize_mcp_transport_alias,
|
|
reload_extensions_config,
|
|
)
|
|
from deerflow.config.runtime_paths import project_root
|
|
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT
|
|
from deerflow.mcp.cache import reset_mcp_tools_cache
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["mcp"])
|
|
|
|
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage MCP configuration."
|
|
|
|
|
|
_MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST"
|
|
_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"})
|
|
_SHELL_METACHARS = frozenset(";|&`$<>\n\r")
|
|
|
|
# Flags that turn an allowlisted launcher into an arbitrary code evaluator.
|
|
# Validating only the command name leaves the allowlist naming a binary
|
|
# without constraining what that binary runs, so these are screened too.
|
|
# The spellings below mean "evaluate this string" across every launcher an
|
|
# operator would plausibly allowlist (npx/uvx `--call`, python/sh `-c`,
|
|
# node/perl/ruby `-e`/`--eval`, node `--print`), plus npx's pass-through into
|
|
# node's own argv.
|
|
#
|
|
# This is defense in depth, not a trust boundary. `npx`/`uvx` exist to fetch
|
|
# and run remote code, so an admin can still point one at a package they
|
|
# published; the boundary remains admin authentication plus not exposing the
|
|
# Gateway to untrusted networks.
|
|
_ARBITRARY_EXEC_ARGS = frozenset(
|
|
{
|
|
"-c",
|
|
"--call",
|
|
"-e",
|
|
"--eval",
|
|
"--print",
|
|
"--shell",
|
|
"--node-arg",
|
|
"--node-options",
|
|
}
|
|
)
|
|
|
|
|
|
# Package launchers parse their own options only until the package name; every
|
|
# later token is handed to the spawned server's own CLI, where `-c` is commonly
|
|
# "config" and `-e` "env". Screening those rejected ordinary third-party servers
|
|
# without covering anything, so the screen is scoped to the option region.
|
|
#
|
|
# Finding that region needs each launcher's option *arity*, because a value is
|
|
# not a positional: `npx -p <pkg> -c '<command>'` runs the command -- `-p` is
|
|
# exec's `--package`, so `<pkg>` is its value and npm keeps parsing its own
|
|
# flags. Ending the region at the first non-flag token would walk past it.
|
|
# (Verified against npm 10.9.4 / uv 0.11.1.)
|
|
#
|
|
# The two launchers get opposite defaults for an option neither table lists,
|
|
# and the reason is the exec set above, not symmetry:
|
|
#
|
|
# `npx` really does own exec flags here (`-c`/`--call`), so an unlisted option
|
|
# must not be able to hide one. Unknown therefore consumes a value, keeping
|
|
# the region open. npm *errors* on an option it does not define, so this
|
|
# cannot reject an invocation that would otherwise work; enumerating npm's
|
|
# booleans (rather than its much larger value-taking set) is what makes the
|
|
# common `npx -y <pkg> ...` shape land on the package name.
|
|
#
|
|
# `uvx` owns no exec flag at all -- uv has no "evaluate this string" option --
|
|
# so its screen is a tripwire, not a control, and an imprecise region cannot
|
|
# walk past anything real. Unknown therefore consumes nothing, which keeps
|
|
# uv's large and growing boolean surface from over-blocking.
|
|
#
|
|
# A launcher outside this table is not a package runner and keeps the
|
|
# conservative whole-args screen below.
|
|
class _LauncherGrammar(NamedTuple):
|
|
"""How one package launcher separates its own options from the server's."""
|
|
|
|
exec_args: frozenset[str]
|
|
known_args: frozenset[str]
|
|
unknown_consumes_value: bool
|
|
|
|
def consumes_value(self, flag: str) -> bool:
|
|
if self.unknown_consumes_value:
|
|
return flag not in self.known_args
|
|
return flag in self.known_args
|
|
|
|
|
|
# npm's boolean configs, i.e. the options that do *not* consume the next token.
|
|
# Generated from `@npmcli/config`'s definitions (npm 10.9.4): every config whose
|
|
# type is Boolean, plus every nopt shorthand expanding to one of them or to a
|
|
# complete assignment such as `-d` -> `--loglevel info`. Regenerate against a
|
|
# newer npm rather than editing by hand. A boolean missing here over-blocks one
|
|
# invocation and names the flag in the rejection, which is the failure direction
|
|
# this file prefers.
|
|
_NPM_BOOLEAN_ARGS = frozenset(
|
|
{
|
|
"--all",
|
|
"--allow-same-version",
|
|
"--audit",
|
|
"--bin-links",
|
|
"--commit-hooks",
|
|
"--description",
|
|
"--dev",
|
|
"--diff-ignore-all-space",
|
|
"--diff-name-only",
|
|
"--diff-no-prefix",
|
|
"--diff-text",
|
|
"--dry-run",
|
|
"--engine-strict",
|
|
"--expect-results",
|
|
"--force",
|
|
"--foreground-scripts",
|
|
"--format-package-lock",
|
|
"--fund",
|
|
"--git-tag-version",
|
|
"--global",
|
|
"--global-style",
|
|
"--if-present",
|
|
"--ignore-scripts",
|
|
"--include-staged",
|
|
"--include-workspace-root",
|
|
"--install-links",
|
|
"--json",
|
|
"--legacy-bundling",
|
|
"--legacy-peer-deps",
|
|
"--link",
|
|
"--long",
|
|
"--offline",
|
|
"--omit-lockfile-registry-resolved",
|
|
"--optional",
|
|
"--package-lock",
|
|
"--package-lock-only",
|
|
"--parseable",
|
|
"--prefer-dedupe",
|
|
"--prefer-offline",
|
|
"--prefer-online",
|
|
"--production",
|
|
"--progress",
|
|
"--provenance",
|
|
"--read-only",
|
|
"--rebuild-bundle",
|
|
"--save",
|
|
"--save-bundle",
|
|
"--save-dev",
|
|
"--save-exact",
|
|
"--save-optional",
|
|
"--save-peer",
|
|
"--save-prod",
|
|
"--shrinkwrap",
|
|
"--sign-git-commit",
|
|
"--sign-git-tag",
|
|
"--strict-peer-deps",
|
|
"--strict-ssl",
|
|
"--timing",
|
|
"--unicode",
|
|
"--update-notifier",
|
|
"--usage",
|
|
"--version",
|
|
"--versions",
|
|
"--workspaces",
|
|
"--workspaces-update",
|
|
"--yes",
|
|
"-?",
|
|
"-B",
|
|
"-D",
|
|
"-E",
|
|
"-H",
|
|
"-O",
|
|
"-P",
|
|
"-S",
|
|
"-a",
|
|
"-d",
|
|
"-dd",
|
|
"-ddd",
|
|
"-desc",
|
|
"-f",
|
|
"-g",
|
|
"-h",
|
|
"-help",
|
|
"-iwr",
|
|
"-l",
|
|
"-local",
|
|
"-n",
|
|
"-no",
|
|
"-porcelain",
|
|
"-q",
|
|
"-quiet",
|
|
"-readonly",
|
|
"-s",
|
|
"-silent",
|
|
"-v",
|
|
"-verbose",
|
|
"-ws",
|
|
"-y",
|
|
}
|
|
)
|
|
|
|
# `npm exec` overrides the global `-p` shorthand: it is `--package <spec>` there,
|
|
# not the boolean `--parseable`. Confirmed by running it -- `npx -p . -c '<cmd>'`
|
|
# executes the command, i.e. `.` was consumed as a value and never ended the
|
|
# option region. Treating it as boolean is exactly the bypass this table exists
|
|
# to prevent, so the override is applied explicitly rather than left implicit.
|
|
_NPX_BOOLEAN_ARGS = _NPM_BOOLEAN_ARGS - {"-p"}
|
|
|
|
# uv's value-taking options (`uvx --help`, uv 0.11.1). Everything absent is
|
|
# treated as boolean; see the unknown-option note above for why that default is
|
|
# safe here and inverted for npx.
|
|
_UVX_VALUE_ARGS = frozenset(
|
|
{
|
|
"--allow-insecure-host",
|
|
"--build-constraints",
|
|
"--cache-dir",
|
|
"--color",
|
|
"--config-file",
|
|
"--config-setting",
|
|
"--config-settings-package",
|
|
"--constraints",
|
|
"--default-index",
|
|
"--directory",
|
|
"--env-file",
|
|
"--exclude-newer",
|
|
"--exclude-newer-package",
|
|
"--extra-index-url",
|
|
"--find-links",
|
|
"--fork-strategy",
|
|
"--from",
|
|
"--index",
|
|
"--index-strategy",
|
|
"--index-url",
|
|
"--keyring-provider",
|
|
"--link-mode",
|
|
"--no-binary-package",
|
|
"--no-build-isolation-package",
|
|
"--no-build-package",
|
|
"--no-sources-package",
|
|
"--overrides",
|
|
"--prerelease",
|
|
"--project",
|
|
"--python",
|
|
"--python-platform",
|
|
"--refresh-package",
|
|
"--reinstall-package",
|
|
"--resolution",
|
|
"--torch-backend",
|
|
"--upgrade-package",
|
|
"--with",
|
|
"--with-editable",
|
|
"--with-requirements",
|
|
"-C",
|
|
"-P",
|
|
"-b",
|
|
"-c",
|
|
"-f",
|
|
"-i",
|
|
"-p",
|
|
"-w",
|
|
}
|
|
)
|
|
|
|
_PACKAGE_LAUNCHERS: dict[str, _LauncherGrammar] = {
|
|
"npx": _LauncherGrammar(
|
|
exec_args=_ARBITRARY_EXEC_ARGS,
|
|
known_args=_NPX_BOOLEAN_ARGS,
|
|
unknown_consumes_value=True,
|
|
),
|
|
# uv spells `-c` `--constraints` and `-p` `--python`, so the short forms are
|
|
# dropped from its exec set; the long spellings stay as a tripwire in case a
|
|
# future uv grows one. Derived so a new entry above cannot forget this.
|
|
"uvx": _LauncherGrammar(
|
|
exec_args=frozenset(flag for flag in _ARBITRARY_EXEC_ARGS if flag.startswith("--")),
|
|
known_args=_UVX_VALUE_ARGS,
|
|
unknown_consumes_value=False,
|
|
),
|
|
}
|
|
|
|
# `-p` is `--print` (evaluate and print) on node, so exempting it everywhere
|
|
# left the short and long spellings of one flag disagreeing as soon as an
|
|
# operator extended the allowlist. It stays scoped to commands outside
|
|
# `_PACKAGE_LAUNCHERS`, where it is an ordinary selector (`--package` for npx,
|
|
# `--python` for uv), so the default allowlist is unaffected.
|
|
_EXEC_ARGS_OUTSIDE_PACKAGE_LAUNCHERS = frozenset({"-p"})
|
|
|
|
# Short options combine into one token (`node -pe`, `perl -we`, `python -Ic`),
|
|
# which whole-token matching does not see. Derived rather than restated so a
|
|
# new single-letter entry above cannot forget its clustered spelling.
|
|
_CLUSTERED_EXEC_LETTERS = frozenset(flag[1] for flag in _ARBITRARY_EXEC_ARGS | _EXEC_ARGS_OUTSIDE_PACKAGE_LAUNCHERS if len(flag) == 2 and flag.startswith("-"))
|
|
|
|
# Environment variables that inject code into a process at startup, which is
|
|
# the same bypass as an exec flag by another name.
|
|
#
|
|
# `PYTHONPATH` matters most: `site` imports `sitecustomize.py` from any
|
|
# `sys.path` entry before the tool's entry point runs, so a caller-controlled
|
|
# directory is code execution under `uvx` -- on the *default* allowlist.
|
|
# `PYTHONSTARTUP` is inert for the non-interactive launchers in scope and is
|
|
# kept only as belt-and-braces for an operator who allowlists a REPL.
|
|
#
|
|
# Known residual, accepted. Every entry below executes code *unconditionally*
|
|
# at process startup. Caller-controlled *search paths* are a different, weaker
|
|
# shape -- they reach code only if the process happens to load a name the
|
|
# caller can shadow -- and they stay out:
|
|
#
|
|
# `LD_LIBRARY_PATH`/`DYLD_LIBRARY_PATH` run a shadowed library's constructor,
|
|
# and native-dependency servers legitimately set them.
|
|
#
|
|
# `NODE_PATH` is narrower still, and not for the reason it first looks like.
|
|
# Node searches it *after* the local `node_modules` chain -- the resolver
|
|
# unshifts the requiring module's own paths ahead of it -- so it cannot
|
|
# shadow an installed dependency, and ESM `import` ignores it entirely. It
|
|
# can only supply a CJS module that would otherwise fail to resolve, i.e. an
|
|
# optional `try { require(...) } catch {}` dependency absent from the install.
|
|
#
|
|
# Adding them would make the "unconditional" rule above untrue, and a
|
|
# defense-in-depth list that grows because each entry was cheap is how it ends
|
|
# up mistaken for a boundary. A denylist is not what makes MCP registration
|
|
# safe for an untrusted admin anyway.
|
|
_CODE_INJECTING_ENV_VARS = frozenset(
|
|
{
|
|
"BASH_ENV",
|
|
"DYLD_INSERT_LIBRARIES",
|
|
"ENV",
|
|
"LD_AUDIT",
|
|
"LD_PRELOAD",
|
|
"NODE_OPTIONS",
|
|
"PERL5OPT",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
"PYTHONSTARTUP",
|
|
"RUBYOPT",
|
|
}
|
|
)
|
|
|
|
|
|
class McpUserScopedAuthConfigResponse(BaseModel):
|
|
"""Per-user credential injection configuration for an MCP server."""
|
|
|
|
enabled: bool = Field(default=True, description="Whether user-scoped credential injection is enabled")
|
|
header: str = Field(default="Authorization", description="HTTP header to set with the resolved user credential")
|
|
users: dict[str, str] = Field(default_factory=dict, description="Map of DeerFlow user id to credential header value")
|
|
on_missing: Literal["deny", "passthrough"] = Field(default="deny", description="Behavior when the calling user has no mapped credential")
|
|
# Mirror the harness-side McpUserScopedAuthConfig (extra="allow"): without
|
|
# this, an operator's unknown key inside user_auth would be silently
|
|
# stripped by the next admin PUT, while server-level extras are preserved.
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
# Mirror the harness-side non-blank check: a blank header accepted here
|
|
# would be persisted, then fail ExtensionsConfig validation on reload —
|
|
# wedging every subsequent config load until the file is hand-edited.
|
|
@field_validator("header")
|
|
@classmethod
|
|
def _validate_header_not_blank(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("user_auth.header must not be empty")
|
|
return value
|
|
|
|
|
|
class McpContextHeadersConfigResponse(BaseModel):
|
|
"""Per-request credential injection configuration for an MCP server.
|
|
|
|
Holds header names and run-context key names only — never a credential —
|
|
so unlike ``user_auth`` its declared fields are returned unmasked by GET.
|
|
"""
|
|
|
|
enabled: bool = Field(default=True, description="Whether request-scoped header injection is enabled")
|
|
headers: dict[str, str] = Field(
|
|
default_factory=dict,
|
|
description="Map of HTTP header name to the key read from the run request's config.context.secrets",
|
|
)
|
|
on_missing: Literal["deny", "passthrough"] = Field(default="deny", description="Behavior when a mapped key is absent from the request secrets")
|
|
# Mirror the harness-side McpContextHeadersConfig (extra="allow"): without
|
|
# this, an operator's unknown key inside headers_from_context would be
|
|
# silently stripped by the next admin PUT.
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
# Mirror the harness-side entry check: a blank name accepted here would be
|
|
# persisted, then fail ExtensionsConfig validation on reload — wedging every
|
|
# subsequent config load until the file is hand-edited.
|
|
@field_validator("headers")
|
|
@classmethod
|
|
def _validate_mapping_entries(cls, value: dict[str, str]) -> dict[str, str]:
|
|
seen: dict[str, str] = {}
|
|
for header_name, secret_key in value.items():
|
|
if not header_name.strip():
|
|
raise ValueError("headers_from_context.headers must not contain a blank header name")
|
|
if not isinstance(secret_key, str) or not secret_key.strip():
|
|
raise ValueError(f"headers_from_context.headers[{header_name!r}] must name a non-blank secret key from config.context.secrets")
|
|
lowered = header_name.lower()
|
|
if lowered in seen:
|
|
raise ValueError(f"headers_from_context.headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
|
|
seen[lowered] = header_name
|
|
return value
|
|
|
|
|
|
class McpOAuthConfigResponse(BaseModel):
|
|
"""OAuth configuration for an MCP server."""
|
|
|
|
enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled")
|
|
token_url: str = Field(default="", description="OAuth token endpoint URL")
|
|
grant_type: Literal["client_credentials", "refresh_token"] = Field(default="client_credentials", description="OAuth grant type")
|
|
client_id: str | None = Field(default=None, description="OAuth client ID")
|
|
client_secret: str | None = Field(default=None, description="OAuth client secret")
|
|
refresh_token: str | None = Field(default=None, description="OAuth refresh token")
|
|
scope: str | None = Field(default=None, description="OAuth scope")
|
|
audience: str | None = Field(default=None, description="OAuth audience")
|
|
token_field: str = Field(default="access_token", description="Token response field containing access token")
|
|
token_type_field: str = Field(default="token_type", description="Token response field containing token type")
|
|
expires_in_field: str = Field(default="expires_in", description="Token response field containing expires-in seconds")
|
|
default_token_type: str = Field(default="Bearer", description="Default token type when response omits token_type")
|
|
refresh_skew_seconds: int = Field(default=60, description="Refresh this many seconds before expiry")
|
|
extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint")
|
|
# Mirror the harness-side McpOAuthConfig (extra="allow"): provider-specific
|
|
# OAuth fields must survive the Gateway's GET -> edit -> PUT round-trip.
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class McpServerConfigResponse(BaseModel):
|
|
"""Response model for MCP server configuration."""
|
|
|
|
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
|
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
|
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
|
args: list[str] = Field(default_factory=list, description="Arguments to pass to the command (for stdio type)")
|
|
env: dict[str, str] = Field(default_factory=dict, description="Environment variables for the MCP server")
|
|
url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)")
|
|
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
|
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
|
|
user_auth: McpUserScopedAuthConfigResponse | None = Field(default=None, description="Per-user credential injection for MCP HTTP/SSE servers")
|
|
headers_from_context: McpContextHeadersConfigResponse | None = Field(default=None, description="Per-request credential injection for MCP HTTP/SSE servers: map header names to config.context.secrets keys")
|
|
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
|
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig, description="Soft routing hints for tools from this MCP server")
|
|
tools: dict[str, McpToolOverride] = Field(default_factory=dict, description="Per-original-tool MCP configuration overrides")
|
|
tool_name_prefix: bool = Field(default=True, description="Whether to prefix discovered tool names with the MCP server name")
|
|
tool_call_timeout: float | None = Field(
|
|
default=None,
|
|
description="Timeout in seconds for individual stdio MCP calls and durable-task calls on every transport",
|
|
)
|
|
# Default matches McpServerConfig: this model's defaults feed model_dump()
|
|
# into the persisted extensions config on PUT, so an API-created server that
|
|
# omits the field must get the same bring-up timeout as a file-created one.
|
|
session_init_timeout: float | None = Field(
|
|
default=DEFAULT_MCP_SESSION_INIT_TIMEOUT,
|
|
description="Timeout in seconds for MCP server bring-up and durable HTTP/SSE task-session initialization; null means no timeout",
|
|
)
|
|
task_toolsets: list[McpTaskToolsetConfig] = Field(
|
|
default_factory=list,
|
|
description="Raw submit/status/cancel tool groups managed as durable background tasks",
|
|
)
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
@field_validator("headers")
|
|
@classmethod
|
|
def _validate_header_names(cls, value: dict[str, str]) -> dict[str, str]:
|
|
# Mirror the harness-side McpServerConfig check: HTTP field names are
|
|
# case-insensitive, so a config carrying one header under two spellings
|
|
# would persist, reload into a connection with both fields, and let a
|
|
# later override replace only one of them. Reject at the API boundary
|
|
# instead of wedging the next ExtensionsConfig reload.
|
|
seen: dict[str, str] = {}
|
|
for header_name in value:
|
|
lowered = header_name.lower()
|
|
if lowered in seen:
|
|
raise ValueError(f"headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
|
|
seen[lowered] = header_name
|
|
return value
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def _accept_transport_alias(cls, data: Any) -> Any:
|
|
"""Keep API parsing aligned with the runtime MCP config model."""
|
|
return normalize_mcp_transport_alias(data)
|
|
|
|
|
|
class McpConfigResponse(BaseModel):
|
|
"""Response model for MCP configuration."""
|
|
|
|
mcp_servers: dict[str, McpServerConfigResponse] = Field(
|
|
default_factory=dict,
|
|
description="Map of MCP server name to configuration",
|
|
)
|
|
|
|
|
|
class McpConfigUpdateRequest(BaseModel):
|
|
"""Request model for updating MCP configuration."""
|
|
|
|
mcp_servers: dict[str, McpServerConfigResponse] = Field(
|
|
...,
|
|
description="Map of MCP server name to configuration",
|
|
)
|
|
|
|
|
|
class McpServerStateUpdateRequest(BaseModel):
|
|
"""Request model for enabling or disabling one MCP server."""
|
|
|
|
server_name: str = Field(
|
|
...,
|
|
description="Name of the MCP server to update",
|
|
)
|
|
enabled: bool = Field(..., description="Whether the MCP server is enabled")
|
|
|
|
|
|
class McpServerConfigUpdateRequest(BaseModel):
|
|
"""Request model for replacing one MCP server configuration."""
|
|
|
|
server_name: str = Field(
|
|
...,
|
|
description="Name of the existing MCP server to update",
|
|
)
|
|
server: McpServerConfigResponse = Field(
|
|
...,
|
|
description="Complete replacement configuration for the selected MCP server",
|
|
)
|
|
|
|
|
|
class McpCacheResetResponse(BaseModel):
|
|
"""Response model for resetting the MCP tools cache."""
|
|
|
|
success: bool = Field(description="Whether the MCP tools cache was reset")
|
|
message: str = Field(description="Human-readable reset status")
|
|
|
|
|
|
_MASKED_VALUE = "***"
|
|
_SENSITIVE_EXTRA_KEY_RE = re.compile(
|
|
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _normalize_config_key(key: str) -> str:
|
|
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
|
|
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
|
|
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
|
|
|
|
|
|
def _is_sensitive_extra_key(key: str) -> bool:
|
|
return bool(_SENSITIVE_EXTRA_KEY_RE.search(_normalize_config_key(key)))
|
|
|
|
|
|
def _mask_sensitive_extra_value(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {key: _MASKED_VALUE if _is_sensitive_extra_key(str(key)) else _mask_sensitive_extra_value(nested) for key, nested in value.items()}
|
|
if isinstance(value, list):
|
|
return [_mask_sensitive_extra_value(item) for item in 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:
|
|
if incoming_value == _MASKED_VALUE and _is_sensitive_extra_key(key):
|
|
if existing_present:
|
|
return existing_value
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot set extra config key '{key}' to masked value '***'; provide a real value.",
|
|
)
|
|
|
|
if isinstance(incoming_value, dict) and isinstance(existing_value, dict):
|
|
merged: dict[str, Any] = {}
|
|
for nested_key, nested_value in incoming_value.items():
|
|
nested_present = nested_key in existing_value
|
|
merged[nested_key] = _merge_extra_value_preserving_masked(
|
|
str(nested_key),
|
|
nested_value,
|
|
existing_value.get(nested_key),
|
|
existing_present=nested_present,
|
|
)
|
|
return merged
|
|
|
|
if isinstance(incoming_value, list) and isinstance(existing_value, list):
|
|
if _contains_masked_sensitive_extra_value(key, incoming_value):
|
|
if incoming_value != _mask_sensitive_extra_value(existing_value):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Cannot edit extra config array '{key}' while masked secrets remain; provide real values for every masked secret.",
|
|
)
|
|
return existing_value
|
|
return incoming_value
|
|
|
|
return incoming_value
|
|
|
|
|
|
def _allowed_stdio_commands() -> set[str]:
|
|
"""Return executable names allowed for API-managed stdio MCP servers."""
|
|
raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV)
|
|
base = set(_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST)
|
|
if raw is None:
|
|
return base
|
|
extra = {item.strip() for item in raw.split(",") if item.strip()}
|
|
return base | extra
|
|
|
|
|
|
def _stdio_command_name(command: str | None, *, server_name: str) -> str:
|
|
"""Normalize and validate a stdio command field from the API boundary."""
|
|
if command is None or not command.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"MCP server '{server_name}' with stdio transport requires a command.",
|
|
)
|
|
|
|
stripped = command.strip()
|
|
has_path_separator = "/" in stripped or "\\" in stripped
|
|
if stripped != command or has_path_separator or any(ch.isspace() for ch in stripped) or any(ch in stripped for ch in _SHELL_METACHARS):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(f"MCP server '{server_name}' command must be a single executable name; put parameters in args instead."),
|
|
)
|
|
|
|
return stripped
|
|
|
|
|
|
def _launcher_option_region(args: list[str], *, grammar: _LauncherGrammar) -> list[str]:
|
|
"""Return the leading args a package launcher parses as its own options.
|
|
|
|
The region ends at a bare ``--`` or at the package name -- the first token
|
|
that is neither a flag nor the value of one. A ``--flag=value`` token
|
|
carries its own value and never consumes the next one.
|
|
|
|
Arity is looked up case-sensitively, because a launcher's short options are:
|
|
npm reads ``-c`` as ``--call`` but ``-C`` as ``--prefix``, which takes a
|
|
value.
|
|
"""
|
|
region: list[str] = []
|
|
index = 0
|
|
while index < len(args):
|
|
arg = args[index]
|
|
if not isinstance(arg, str):
|
|
break
|
|
token = arg.strip()
|
|
if token == "--" or token == "-" or not token.startswith("-"):
|
|
break
|
|
region.append(token)
|
|
index += 1
|
|
if "=" not in token and grammar.consumes_value(token):
|
|
index += 1
|
|
return region
|
|
|
|
|
|
def _arbitrary_exec_arg(args: list[str], *, command: str) -> str | None:
|
|
"""Return the offending flag when an argument makes the launcher eval a string.
|
|
|
|
Handles both ``--call value`` and ``--call=value`` spellings.
|
|
|
|
For a package launcher (:data:`_PACKAGE_LAUNCHERS`) only the launcher's own
|
|
option region is screened, because everything from the package name onward
|
|
is the spawned server's argv -- ``npx -y <pkg> -c config.json`` hands
|
|
``-c config.json`` to the server, where it is "config", not eval. A bare
|
|
``--`` ends the region too: only the *first* token after it is the package
|
|
name, and the rest are that package's arguments.
|
|
|
|
Every other command is screened whole, and two extra rules apply because
|
|
such a command is an interpreter rather than a package runner: ``-p`` is an
|
|
exec flag (node's ``--print``) instead of a package/python selector, and
|
|
combined short-option clusters are decomposed so ``-pe`` cannot smuggle
|
|
past a check that only splits on ``=``.
|
|
|
|
Only the normalized flag is returned, never the caller's value, so the
|
|
rejection message does not echo a payload string back into the response.
|
|
"""
|
|
grammar = _PACKAGE_LAUNCHERS.get(command.lower())
|
|
if grammar is not None:
|
|
for token in _launcher_option_region(args, grammar=grammar):
|
|
flag = token.split("=", 1)[0]
|
|
# Long options are matched case-insensitively as before; a short one
|
|
# is not, because its case selects a different option -- npm's `-C`
|
|
# is `--prefix`, and folding it onto `-c` rejected an ordinary flag.
|
|
flag = flag.lower() if flag.startswith("--") else flag
|
|
if flag in grammar.exec_args:
|
|
return flag
|
|
return None
|
|
|
|
denied = _ARBITRARY_EXEC_ARGS | _EXEC_ARGS_OUTSIDE_PACKAGE_LAUNCHERS
|
|
for arg in args:
|
|
if not isinstance(arg, str):
|
|
continue
|
|
flag = arg.split("=", 1)[0].strip().lower()
|
|
if flag in denied:
|
|
return flag
|
|
if not flag.startswith("-") or flag.startswith("--"):
|
|
continue
|
|
for letter in flag[1:]:
|
|
if letter in _CLUSTERED_EXEC_LETTERS:
|
|
return f"-{letter}"
|
|
return None
|
|
|
|
|
|
def _validate_mcp_update_request(
|
|
request: McpConfigUpdateRequest,
|
|
*,
|
|
enforce_execution_policy: bool = True,
|
|
) -> None:
|
|
"""Validate API-submitted MCP config before it is persisted.
|
|
|
|
Local config files can still express arbitrary advanced setups, but the
|
|
HTTP API is an untrusted boundary. Restricting stdio commands here reduces
|
|
the blast radius of a compromised authenticated browser session.
|
|
|
|
Command shape and code-injecting environment variables are invalid at the
|
|
API boundary even while a server remains disabled. The allowlist and its
|
|
companion argument screen are execution policy, so targeted offline edits
|
|
may defer only those checks until the server is enabled.
|
|
"""
|
|
allowed_commands = _allowed_stdio_commands() if enforce_execution_policy else set()
|
|
for name, server in request.mcp_servers.items():
|
|
transport_type = (server.type or "stdio").lower()
|
|
if transport_type != "stdio":
|
|
continue
|
|
|
|
command_name = _stdio_command_name(server.command, server_name=name)
|
|
if enforce_execution_policy:
|
|
if command_name not in allowed_commands:
|
|
allowed = ", ".join(sorted(allowed_commands)) or "<none>"
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."),
|
|
)
|
|
|
|
exec_flag = _arbitrary_exec_arg(server.args, command=command_name)
|
|
if exec_flag is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(f"MCP server '{name}' passes '{exec_flag}' to '{command_name}', which would run arbitrary code. Point the server at a package or module instead."),
|
|
)
|
|
|
|
for env_name in server.env:
|
|
if env_name.strip().upper() in _CODE_INJECTING_ENV_VARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(f"MCP server '{name}' sets environment variable '{env_name}', which would run arbitrary code at process startup."),
|
|
)
|
|
|
|
|
|
def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigResponse:
|
|
"""Return a copy of server config with sensitive fields masked.
|
|
|
|
Masks env values, header values, and removes OAuth secrets so they
|
|
are not exposed through the GET API endpoint.
|
|
"""
|
|
masked_env = {k: _MASKED_VALUE for k in server.env}
|
|
masked_headers = {k: _MASKED_VALUE for k in server.headers}
|
|
masked_oauth = None
|
|
if server.oauth is not None:
|
|
# These values are arbitrary form fields sent directly to the token
|
|
# endpoint. Treat the whole map as credential-bearing instead of
|
|
# trying to recognize secrets from an open-ended key vocabulary.
|
|
masked_extra_token_params = {key: _MASKED_VALUE for key in server.oauth.extra_token_params}
|
|
masked_oauth_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.oauth.model_extra or {}).items()}
|
|
masked_oauth = server.oauth.model_copy(
|
|
update={
|
|
"client_secret": None,
|
|
"refresh_token": None,
|
|
"extra_token_params": masked_extra_token_params,
|
|
**masked_oauth_extra,
|
|
}
|
|
)
|
|
masked_user_auth = None
|
|
if server.user_auth is not None:
|
|
# Extras inside user_auth get the same sensitive-key masking as
|
|
# server-level extras: they round-trip through PUT (extra="allow"), so
|
|
# an operator-stored secret-bearing key must not come back in
|
|
# cleartext from GET while the identical key at server level is masked.
|
|
masked_ua_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.user_auth.model_extra or {}).items()}
|
|
masked_user_auth = server.user_auth.model_copy(update={"users": {k: _MASKED_VALUE for k in server.user_auth.users}, **masked_ua_extra})
|
|
masked_headers_from_context = None
|
|
if server.headers_from_context is not None:
|
|
# The declared fields hold names only and stay in cleartext — masking
|
|
# them would show operators `***` where a header name belongs. Extras
|
|
# get the same treatment as everywhere else, since `extra="allow"` lets
|
|
# an operator store a secret-bearing key that round-trips through PUT.
|
|
masked_ch_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.headers_from_context.model_extra or {}).items()}
|
|
masked_headers_from_context = server.headers_from_context.model_copy(update=masked_ch_extra)
|
|
masked_tools = {}
|
|
for tool_name, tool_override in server.tools.items():
|
|
masked_tool_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (tool_override.model_extra or {}).items()}
|
|
masked_tools[tool_name] = tool_override.model_copy(update=masked_tool_extra)
|
|
masked_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.model_extra or {}).items()}
|
|
return server.model_copy(
|
|
update={
|
|
"env": masked_env,
|
|
"headers": masked_headers,
|
|
"oauth": masked_oauth,
|
|
"user_auth": masked_user_auth,
|
|
"headers_from_context": masked_headers_from_context,
|
|
"tools": masked_tools,
|
|
**masked_extra,
|
|
}
|
|
)
|
|
|
|
|
|
def _merge_preserving_secrets(
|
|
incoming: McpServerConfigResponse,
|
|
existing: McpServerConfigResponse,
|
|
*,
|
|
preserve_omitted_fields: bool = True,
|
|
) -> McpServerConfigResponse:
|
|
"""Merge incoming config with existing, preserving secrets masked by GET.
|
|
|
|
When the frontend toggles ``enabled`` it round-trips the full config:
|
|
GET (masked) → modify enabled → PUT (masked values sent back).
|
|
This function ensures masked values (``***``) are replaced with the
|
|
real secrets from the current on-disk config.
|
|
|
|
``***`` is only accepted for keys that already exist in *existing*.
|
|
New keys must provide a real value.
|
|
|
|
For OAuth secrets, ``None`` means "preserve the existing stored value"
|
|
so masked GET responses can be safely round-tripped. To explicitly clear
|
|
a stored secret, clients may send an empty string, which is converted
|
|
to ``None`` before persisting.
|
|
|
|
``preserve_omitted_fields`` keeps the legacy bulk PUT's partial-update
|
|
behavior. Targeted PUT disables it because that endpoint is a complete
|
|
replacement: omissions must delete/reset ordinary fields, while explicit
|
|
masked placeholders still restore only their matching stored secrets.
|
|
"""
|
|
merged_env = {}
|
|
for k, v in incoming.env.items():
|
|
if v == _MASKED_VALUE:
|
|
if k in existing.env:
|
|
merged_env[k] = existing.env[k]
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot set env key '{k}' to masked value '***'; provide a real value.",
|
|
)
|
|
else:
|
|
merged_env[k] = v
|
|
|
|
merged_headers = {}
|
|
for k, v in incoming.headers.items():
|
|
if v == _MASKED_VALUE:
|
|
if k in existing.headers:
|
|
merged_headers[k] = existing.headers[k]
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot set header '{k}' to masked value '***'; provide a real value.",
|
|
)
|
|
else:
|
|
merged_headers[k] = v
|
|
|
|
merged_oauth = incoming.oauth
|
|
if incoming.oauth is not None:
|
|
incoming_oauth = incoming.oauth
|
|
base_oauth = existing.oauth
|
|
base_extra_token_params = base_oauth.extra_token_params if base_oauth is not None else {}
|
|
merged_extra_token_params: dict[str, str] = {}
|
|
for key, value in incoming_oauth.extra_token_params.items():
|
|
if value == _MASKED_VALUE:
|
|
if key not in base_extra_token_params:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot set oauth extra_token_params key '{key}' to masked value '***'; provide a real value.",
|
|
)
|
|
merged_extra_token_params[key] = base_extra_token_params[key]
|
|
else:
|
|
merged_extra_token_params[key] = value
|
|
if preserve_omitted_fields and "extra_token_params" not in incoming_oauth.model_fields_set:
|
|
merged_extra_token_params = dict(base_extra_token_params)
|
|
|
|
base_oauth_extra = (base_oauth.model_extra or {}) if base_oauth is not None else {}
|
|
merged_oauth_extra: dict[str, Any] = {}
|
|
for key, value in (incoming_oauth.model_extra or {}).items():
|
|
merged_oauth_extra[key] = _merge_extra_value_preserving_masked(
|
|
key,
|
|
value,
|
|
base_oauth_extra.get(key),
|
|
existing_present=key in base_oauth_extra,
|
|
)
|
|
if preserve_omitted_fields:
|
|
for key, value in base_oauth_extra.items():
|
|
if key not in (incoming_oauth.model_extra or {}):
|
|
merged_oauth_extra[key] = value
|
|
|
|
if base_oauth is not None:
|
|
# None = preserve (masked round-trip), "" = explicitly clear,
|
|
# else = new value.
|
|
merged_client_secret = base_oauth.client_secret if incoming_oauth.client_secret is None else (None if incoming_oauth.client_secret == "" else incoming_oauth.client_secret)
|
|
merged_refresh_token = base_oauth.refresh_token if incoming_oauth.refresh_token is None else (None if incoming_oauth.refresh_token == "" else incoming_oauth.refresh_token)
|
|
else:
|
|
merged_client_secret = incoming_oauth.client_secret
|
|
merged_refresh_token = incoming_oauth.refresh_token
|
|
merged_oauth = incoming_oauth.model_copy(
|
|
update={
|
|
"client_secret": merged_client_secret,
|
|
"refresh_token": merged_refresh_token,
|
|
"extra_token_params": merged_extra_token_params,
|
|
**merged_oauth_extra,
|
|
}
|
|
)
|
|
merged_user_auth = incoming.user_auth
|
|
if incoming.user_auth is not None:
|
|
incoming_ua = incoming.user_auth
|
|
base = existing.user_auth
|
|
set_fields = incoming_ua.model_fields_set
|
|
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():
|
|
merged_extra[key] = _merge_extra_value_preserving_masked(
|
|
key,
|
|
value,
|
|
base_extra.get(key),
|
|
existing_present=key in base_extra,
|
|
)
|
|
|
|
existing_users = base.users if base is not None else {}
|
|
merged_users = {}
|
|
for k, v in incoming_ua.users.items():
|
|
if v == _MASKED_VALUE:
|
|
if k in existing_users:
|
|
merged_users[k] = existing_users[k]
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot set user_auth credential for '{k}' to masked value '***'; provide a real value.",
|
|
)
|
|
else:
|
|
merged_users[k] = v
|
|
|
|
if preserve_omitted_fields:
|
|
# A partial user_auth payload (for example only enabled=false)
|
|
# inherits omitted sub-fields under the legacy bulk PUT contract.
|
|
effective: dict[str, Any] = {}
|
|
if base is not None:
|
|
effective.update({name: getattr(base, name) for name in ("enabled", "header", "users", "on_missing")})
|
|
effective.update(base_extra)
|
|
for name in ("enabled", "header", "on_missing"):
|
|
if name in set_fields:
|
|
effective[name] = getattr(incoming_ua, name)
|
|
effective.update(merged_extra)
|
|
if "users" in set_fields:
|
|
effective["users"] = merged_users
|
|
merged_user_auth = McpUserScopedAuthConfigResponse(**effective)
|
|
else:
|
|
# Targeted PUT is a complete replacement. Start from the incoming
|
|
# block so omitted ordinary sub-fields reset and omitted extras or
|
|
# users disappear; only explicit masked values above are restored.
|
|
merged_user_auth = incoming_ua.model_copy(
|
|
update={"users": merged_users, **merged_extra},
|
|
)
|
|
|
|
merged_context_headers = incoming.headers_from_context
|
|
if incoming.headers_from_context is not None:
|
|
incoming_ch = incoming.headers_from_context
|
|
base_ch = existing.headers_from_context
|
|
set_fields = incoming_ch.model_fields_set
|
|
# Extras are masked by GET (see _mask_server_config), so a round-trip
|
|
# PUT must swap masked sentinel values back for the stored ones — the
|
|
# same contract user_auth extras and server-level extras get.
|
|
base_ch_extra = (base_ch.model_extra or {}) if base_ch is not None else {}
|
|
merged_ch_extra: dict[str, Any] = {}
|
|
for key, value in (incoming_ch.model_extra or {}).items():
|
|
merged_ch_extra[key] = _merge_extra_value_preserving_masked(
|
|
key,
|
|
value,
|
|
base_ch_extra.get(key),
|
|
existing_present=key in base_ch_extra,
|
|
)
|
|
|
|
if preserve_omitted_fields:
|
|
# The legacy bulk PUT accepts partial nested blocks. Only fields
|
|
# the request set are replaced, while omitted fields carry over.
|
|
effective: dict[str, Any] = {}
|
|
if base_ch is not None:
|
|
effective.update({name: getattr(base_ch, name) for name in ("enabled", "headers", "on_missing")})
|
|
effective.update(base_ch_extra)
|
|
for name in ("enabled", "headers", "on_missing"):
|
|
if name in set_fields:
|
|
effective[name] = getattr(incoming_ch, name)
|
|
effective.update(merged_ch_extra)
|
|
merged_context_headers = McpContextHeadersConfigResponse(**effective)
|
|
else:
|
|
# The targeted PUT is a complete replacement. Omitted ordinary
|
|
# fields and extras reset/disappear; explicit masked extras alone
|
|
# are restored from the stored block.
|
|
merged_context_headers = incoming_ch.model_copy(update=merged_ch_extra)
|
|
|
|
merged_tools = {}
|
|
for tool_name, incoming_tool in incoming.tools.items():
|
|
base_tool = existing.tools.get(tool_name)
|
|
base_tool_extra = (base_tool.model_extra or {}) if base_tool is not None else {}
|
|
merged_tool_extra: dict[str, Any] = {}
|
|
for key, value in (incoming_tool.model_extra or {}).items():
|
|
merged_tool_extra[key] = _merge_extra_value_preserving_masked(
|
|
key,
|
|
value,
|
|
base_tool_extra.get(key),
|
|
existing_present=key in base_tool_extra,
|
|
)
|
|
if preserve_omitted_fields:
|
|
for key, value in base_tool_extra.items():
|
|
if key not in (incoming_tool.model_extra or {}):
|
|
merged_tool_extra[key] = value
|
|
merged_routing = incoming_tool.routing
|
|
if preserve_omitted_fields and base_tool is not None and "routing" not in incoming_tool.model_fields_set:
|
|
merged_routing = base_tool.routing
|
|
merged_tools[tool_name] = incoming_tool.model_copy(
|
|
update={"routing": merged_routing, **merged_tool_extra},
|
|
)
|
|
|
|
update = {
|
|
"env": merged_env,
|
|
"headers": merged_headers,
|
|
"oauth": merged_oauth,
|
|
"user_auth": merged_user_auth,
|
|
"headers_from_context": merged_context_headers,
|
|
"tools": merged_tools,
|
|
}
|
|
if preserve_omitted_fields and "user_auth" not in incoming.model_fields_set:
|
|
update["user_auth"] = existing.user_auth
|
|
if preserve_omitted_fields and "headers_from_context" not in incoming.model_fields_set:
|
|
update["headers_from_context"] = existing.headers_from_context
|
|
if preserve_omitted_fields and "routing" not in incoming.model_fields_set:
|
|
update["routing"] = existing.routing
|
|
if preserve_omitted_fields and "tools" not in incoming.model_fields_set:
|
|
update["tools"] = existing.tools
|
|
incoming_extra = incoming.model_extra or {}
|
|
existing_extra = existing.model_extra or {}
|
|
for key, value in incoming_extra.items():
|
|
update[key] = _merge_extra_value_preserving_masked(
|
|
key,
|
|
value,
|
|
existing_extra.get(key),
|
|
existing_present=key in existing_extra,
|
|
)
|
|
if preserve_omitted_fields:
|
|
for key, value in (existing.model_extra or {}).items():
|
|
if key not in (incoming.model_extra or {}):
|
|
update[key] = value
|
|
merged = incoming.model_copy(update=update)
|
|
_ensure_no_masked_secrets(merged)
|
|
return merged
|
|
|
|
|
|
@router.get(
|
|
"/mcp/config",
|
|
response_model=McpConfigResponse,
|
|
summary="Get MCP Configuration",
|
|
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
|
)
|
|
async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
|
"""Get the current MCP configuration.
|
|
|
|
Returns:
|
|
The current MCP configuration with all servers.
|
|
|
|
Example:
|
|
```json
|
|
{
|
|
"mcp_servers": {
|
|
"github": {
|
|
"enabled": true,
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
"env": {"GITHUB_TOKEN": "***"},
|
|
"description": "GitHub MCP server for repository operations"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
|
|
raw_servers = await asyncio.to_thread(_load_raw_mcp_server_responses)
|
|
servers = {name: _mask_server_config(server) for name, server in raw_servers.items()}
|
|
return McpConfigResponse(mcp_servers=servers)
|
|
|
|
|
|
def _raise_invalid_mcp_configuration(detail: str, *, cause: Exception | None = None) -> NoReturn:
|
|
error = HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid MCP configuration: {detail}",
|
|
)
|
|
if cause is not None:
|
|
raise error from cause
|
|
raise error
|
|
|
|
|
|
def _validation_error_summary(exc: ValidationError) -> str:
|
|
errors = exc.errors(include_url=False, include_input=False)
|
|
return "; ".join(f"{'.'.join(str(part) for part in error['loc']) or 'config'}: {error['msg']}" for error in errors)
|
|
|
|
|
|
def _mcp_server_response_from_raw(server_name: str, raw_server: Any) -> McpServerConfigResponse:
|
|
try:
|
|
return McpServerConfigResponse.model_validate(raw_server)
|
|
except ValidationError as exc:
|
|
_raise_invalid_mcp_configuration(f"mcpServers.{server_name}: {_validation_error_summary(exc)}", cause=exc)
|
|
|
|
|
|
def _validate_extensions_config_candidate(raw_data: dict) -> None:
|
|
"""Reject a runtime-invalid candidate without changing its placeholders."""
|
|
try:
|
|
resolved_data = ExtensionsConfig.resolve_env_variables(raw_data)
|
|
ExtensionsConfig.model_validate(resolved_data)
|
|
except ValidationError as exc:
|
|
_raise_invalid_mcp_configuration(_validation_error_summary(exc), cause=exc)
|
|
|
|
|
|
def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
|
|
"""Worker-thread body for :func:`update_mcp_configuration`.
|
|
|
|
Resolving the config path, the existence probe, reading the raw JSON,
|
|
writing the merged config, and reloading it are all blocking filesystem IO
|
|
that must stay off the event loop. The merge is pure in-memory work but
|
|
lives here too so the whole read-modify-write is a single worker hop.
|
|
Returns the reloaded MCP server configs for the response.
|
|
"""
|
|
# Resolve before entering the critical section so every writer locks the
|
|
# same sidecar path for the complete read-modify-write cycle.
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
config_path = project_root() / "extensions_config.json"
|
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
|
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
# Load raw (un-resolved) JSON from disk to use as the merge source.
|
|
# This preserves $VAR placeholders in env values and top-level keys
|
|
# like mcpInterceptors that would otherwise be lost.
|
|
raw_data = _load_raw_extensions_config(config_path, create=True)
|
|
raw_servers = _raw_mcp_servers(raw_data)
|
|
raw_other_keys: dict = {}
|
|
raw_skills: dict[str, dict] | None = None
|
|
if isinstance(raw_data.get("skills"), dict):
|
|
raw_skills = raw_data["skills"]
|
|
# Preserve any top-level keys beyond mcpServers/skills
|
|
for key, value in raw_data.items():
|
|
if key not in ("mcpServers", "skills"):
|
|
raw_other_keys[key] = value
|
|
|
|
# Merge incoming server configs with raw on-disk secrets
|
|
merged_servers: dict[str, McpServerConfigResponse] = {}
|
|
for name, incoming in body.mcp_servers.items():
|
|
raw_server = raw_servers.get(name)
|
|
if raw_server is not None:
|
|
merged = _merge_preserving_secrets(
|
|
incoming,
|
|
_mcp_server_response_from_raw(name, raw_server),
|
|
)
|
|
else:
|
|
merged = incoming
|
|
_ensure_no_masked_secrets(merged)
|
|
merged_servers[name] = merged
|
|
|
|
# Build config data preserving all top-level keys from the original file
|
|
config_data = dict(raw_other_keys)
|
|
config_data["mcpServers"] = {name: server.model_dump() for name, server in merged_servers.items()}
|
|
if raw_skills is None:
|
|
current_config = get_extensions_config()
|
|
raw_skills = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
|
config_data["skills"] = raw_skills
|
|
|
|
_validate_extensions_config_candidate(config_data)
|
|
atomic_write_extensions_config(config_path, config_data)
|
|
|
|
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
|
|
|
# Reload the Gateway configuration and update the global cache. The
|
|
# agent runtime lives in Gateway, so this keeps API reads and tool
|
|
# execution aligned after extensions_config.json changes.
|
|
reload_extensions_config()
|
|
return _mcp_server_responses_from_raw(config_data)
|
|
|
|
|
|
def _apply_mcp_server_state_update(body: McpServerStateUpdateRequest) -> dict:
|
|
"""Update one server state while preserving the raw extensions config."""
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"MCP server '{body.server_name}' not found",
|
|
)
|
|
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
if not config_path.exists():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"MCP server '{body.server_name}' not found",
|
|
)
|
|
|
|
raw_data = _load_raw_extensions_config(config_path, create=False)
|
|
raw_servers = _raw_mcp_servers(raw_data)
|
|
if body.server_name not in raw_servers:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"MCP server '{body.server_name}' not found",
|
|
)
|
|
raw_server = raw_servers[body.server_name]
|
|
target_server = _mcp_server_response_from_raw(body.server_name, raw_server)
|
|
|
|
if body.enabled:
|
|
_validate_mcp_update_request(
|
|
McpConfigUpdateRequest(
|
|
mcp_servers={body.server_name: target_server},
|
|
)
|
|
)
|
|
|
|
raw_server["enabled"] = body.enabled
|
|
_validate_extensions_config_candidate(raw_data)
|
|
atomic_write_extensions_config(config_path, raw_data)
|
|
|
|
logger.info("MCP server %s enabled state updated to %s", body.server_name, body.enabled)
|
|
reload_extensions_config()
|
|
return _mcp_server_responses_from_raw(raw_data)
|
|
|
|
|
|
def _mcp_config_path(*, create: bool) -> Path:
|
|
"""Resolve the shared extensions config path for a targeted mutation."""
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
if create:
|
|
config_path = project_root() / "extensions_config.json"
|
|
logger.info("No existing extensions config found. Creating new config at: %s", config_path)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="MCP configuration not found",
|
|
)
|
|
return config_path
|
|
|
|
|
|
def _load_raw_extensions_config(config_path: Path, *, create: bool) -> dict:
|
|
if config_path.exists():
|
|
try:
|
|
with open(config_path, encoding="utf-8") as f:
|
|
raw_data = json.load(f)
|
|
except json.JSONDecodeError as exc:
|
|
_raise_invalid_mcp_configuration(
|
|
f"Extensions configuration is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}",
|
|
cause=exc,
|
|
)
|
|
if not isinstance(raw_data, dict):
|
|
_raise_invalid_mcp_configuration("Extensions configuration must be a JSON object")
|
|
return raw_data
|
|
if not create:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="MCP configuration not found",
|
|
)
|
|
return {}
|
|
|
|
|
|
def _raw_mcp_servers(raw_data: dict) -> dict[str, dict]:
|
|
raw_servers = raw_data.get("mcpServers", {})
|
|
if not isinstance(raw_servers, dict):
|
|
_raise_invalid_mcp_configuration("`mcpServers` must be a JSON object")
|
|
return raw_servers
|
|
|
|
|
|
def _mcp_server_responses_from_raw(raw_data: dict) -> dict[str, McpServerConfigResponse]:
|
|
"""Build editable API models without expanding environment placeholders."""
|
|
return {name: _mcp_server_response_from_raw(name, server) for name, server in _raw_mcp_servers(raw_data).items()}
|
|
|
|
|
|
def _load_raw_mcp_server_responses() -> dict[str, McpServerConfigResponse]:
|
|
"""Read editable MCP server definitions under the shared config lock."""
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
return {}
|
|
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
raw_data = _load_raw_extensions_config(config_path, create=False)
|
|
return _mcp_server_responses_from_raw(raw_data)
|
|
|
|
|
|
def _ensure_skills_key(raw_data: dict) -> None:
|
|
if isinstance(raw_data.get("skills"), dict):
|
|
return
|
|
current_config = get_extensions_config()
|
|
raw_data["skills"] = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
|
|
|
|
|
def _apply_mcp_servers_create(body: McpConfigUpdateRequest) -> dict:
|
|
"""Atomically add servers without replacing entries already on disk."""
|
|
config_path = _mcp_config_path(create=True)
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
raw_data = _load_raw_extensions_config(config_path, create=True)
|
|
raw_servers = _raw_mcp_servers(raw_data)
|
|
duplicate = next((name for name in body.mcp_servers if name in raw_servers), None)
|
|
if duplicate is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"MCP server '{duplicate}' already exists",
|
|
)
|
|
|
|
for name, incoming in body.mcp_servers.items():
|
|
_ensure_no_masked_secrets(incoming)
|
|
raw_servers[name] = incoming.model_dump()
|
|
raw_data["mcpServers"] = raw_servers
|
|
_ensure_skills_key(raw_data)
|
|
_validate_extensions_config_candidate(raw_data)
|
|
atomic_write_extensions_config(config_path, raw_data)
|
|
|
|
logger.info("Added MCP servers: %s", ", ".join(body.mcp_servers))
|
|
reload_extensions_config()
|
|
return _mcp_server_responses_from_raw(raw_data)
|
|
|
|
|
|
def _apply_mcp_server_config_update(body: McpServerConfigUpdateRequest) -> dict:
|
|
"""Atomically replace one server while preserving concurrent sibling edits."""
|
|
config_path = _mcp_config_path(create=False)
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
raw_data = _load_raw_extensions_config(config_path, create=False)
|
|
raw_servers = _raw_mcp_servers(raw_data)
|
|
if body.server_name not in raw_servers:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"MCP server '{body.server_name}' not found",
|
|
)
|
|
existing_server = _mcp_server_response_from_raw(body.server_name, raw_servers[body.server_name])
|
|
|
|
merged = _merge_preserving_secrets(
|
|
body.server,
|
|
existing_server,
|
|
preserve_omitted_fields=False,
|
|
)
|
|
_ensure_no_masked_secrets(merged)
|
|
raw_servers[body.server_name] = merged.model_dump()
|
|
raw_data["mcpServers"] = raw_servers
|
|
_validate_extensions_config_candidate(raw_data)
|
|
atomic_write_extensions_config(config_path, raw_data)
|
|
|
|
logger.info("Updated MCP server: %s", body.server_name)
|
|
reload_extensions_config()
|
|
return _mcp_server_responses_from_raw(raw_data)
|
|
|
|
|
|
def _apply_mcp_server_delete(server_name: str) -> dict:
|
|
"""Atomically remove one server while preserving every sibling entry."""
|
|
config_path = _mcp_config_path(create=False)
|
|
with extensions_config_write_lock, extensions_config_file_lock(config_path):
|
|
raw_data = _load_raw_extensions_config(config_path, create=False)
|
|
raw_servers = _raw_mcp_servers(raw_data)
|
|
if server_name not in raw_servers:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"MCP server '{server_name}' not found",
|
|
)
|
|
|
|
del raw_servers[server_name]
|
|
raw_data["mcpServers"] = raw_servers
|
|
_validate_extensions_config_candidate(raw_data)
|
|
atomic_write_extensions_config(config_path, raw_data)
|
|
|
|
logger.info("Deleted MCP server: %s", server_name)
|
|
reload_extensions_config()
|
|
return _mcp_server_responses_from_raw(raw_data)
|
|
|
|
|
|
@router.post(
|
|
"/mcp/cache/reset",
|
|
response_model=McpCacheResetResponse,
|
|
summary="Reset MCP Tools Cache",
|
|
description=("Reset cached MCP tools and pooled sessions process-wide so tools are reloaded on next use. This affects all threads and users in the current Gateway process."),
|
|
)
|
|
async def reset_mcp_tools_cache_endpoint(request: Request) -> McpCacheResetResponse:
|
|
"""Reset cached MCP tools and persistent sessions process-wide.
|
|
|
|
The next agent run or tool lookup will reload tools from the configured MCP
|
|
servers. This affects all threads and users in the current Gateway process,
|
|
and avoids relying on extensions_config.json mtime changes.
|
|
"""
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
reset_mcp_tools_cache()
|
|
return McpCacheResetResponse(
|
|
success=True,
|
|
message="MCP tools cache reset. Tools will reload on next use.",
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/mcp/config",
|
|
response_model=McpConfigResponse,
|
|
summary="Update MCP Configuration",
|
|
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
|
)
|
|
async def update_mcp_configuration(request: Request, body: McpConfigUpdateRequest) -> McpConfigResponse:
|
|
"""Update the MCP configuration.
|
|
|
|
This will:
|
|
1. Save the new configuration to the mcp_config.json file
|
|
2. Reload the configuration cache
|
|
3. Reset MCP tools cache to trigger reinitialization
|
|
|
|
Args:
|
|
request: The new MCP configuration to save.
|
|
|
|
Returns:
|
|
The updated MCP configuration.
|
|
|
|
Raises:
|
|
HTTPException: 500 if the configuration file cannot be written.
|
|
|
|
Example Request:
|
|
```json
|
|
{
|
|
"mcp_servers": {
|
|
"github": {
|
|
"enabled": true,
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"},
|
|
"description": "GitHub MCP server for repository operations"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
_validate_mcp_update_request(body)
|
|
|
|
# Offload the blocking read-modify-write of extensions_config.json
|
|
# (path resolve, existence probe, raw read, merged write, reload). The
|
|
# worker takes extensions_config_write_lock for the whole RMW, so it stays
|
|
# atomic and serialized against the skills router (the other writer of
|
|
# this file) even if this request is cancelled mid-write.
|
|
reloaded_servers = await asyncio.to_thread(_apply_mcp_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(f"Failed to update MCP configuration: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/mcp/config/servers",
|
|
response_model=McpConfigResponse,
|
|
summary="Add MCP Servers",
|
|
description="Add one or more MCP servers without replacing existing configurations.",
|
|
)
|
|
async def create_mcp_servers(request: Request, body: McpConfigUpdateRequest) -> McpConfigResponse:
|
|
"""Add servers atomically and reject names that already exist."""
|
|
try:
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
_validate_mcp_update_request(body)
|
|
reloaded_servers = await asyncio.to_thread(_apply_mcp_servers_create, body)
|
|
|
|
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
|
reset_mcp_tools_cache()
|
|
return McpConfigResponse(mcp_servers=servers)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to add MCP servers: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to add MCP servers: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/mcp/config/server",
|
|
response_model=McpConfigResponse,
|
|
summary="Update MCP Server",
|
|
description="Replace one MCP server without replacing sibling configurations.",
|
|
)
|
|
async def update_mcp_server(request: Request, body: McpServerConfigUpdateRequest) -> McpConfigResponse:
|
|
"""Update one existing server and reload the MCP tool cache."""
|
|
try:
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
_validate_mcp_update_request(
|
|
McpConfigUpdateRequest(mcp_servers={body.server_name: body.server}),
|
|
enforce_execution_policy=body.server.enabled,
|
|
)
|
|
reloaded_servers = await asyncio.to_thread(_apply_mcp_server_config_update, body)
|
|
|
|
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
|
reset_mcp_tools_cache()
|
|
return McpConfigResponse(mcp_servers=servers)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update MCP server %s: %s", body.server_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update MCP server: {str(e)}")
|
|
|
|
|
|
@router.delete(
|
|
"/mcp/config/servers/{server_name:path}",
|
|
response_model=McpConfigResponse,
|
|
summary="Delete MCP Server",
|
|
description="Delete one MCP server without replacing sibling configurations.",
|
|
)
|
|
async def delete_mcp_server(request: Request, server_name: str) -> McpConfigResponse:
|
|
"""Delete one existing server and reload the MCP tool cache."""
|
|
try:
|
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
|
reloaded_servers = await asyncio.to_thread(_apply_mcp_server_delete, server_name)
|
|
|
|
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_servers.items()}
|
|
reset_mcp_tools_cache()
|
|
return McpConfigResponse(mcp_servers=servers)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to delete MCP server %s: %s", server_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete MCP server: {str(e)}")
|
|
|
|
|
|
@router.patch(
|
|
"/mcp/config",
|
|
response_model=McpConfigResponse,
|
|
summary="Update MCP Server State",
|
|
description="Enable or disable one MCP server without replacing the full extensions configuration.",
|
|
)
|
|
async def update_mcp_server_state(request: Request, body: McpServerStateUpdateRequest) -> McpConfigResponse:
|
|
"""Enable or disable one MCP 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_state_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 state: %s", body.server_name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update MCP server state: {str(e)}")
|