fix(mcp): 修复配置导入、凭据管理与协议边界

修复生命周期锁阻塞事件循环、旧连接回调误停新连接及超时契约不一致。

补齐 MCP JSON 兼容导入、密钥拆分与失败重试,修复 Header 大小写草稿丢失,迁移大小写敏感的环境变量凭据。

在 SSE 行拼接前限制缓冲大小,增加并发、迁移和流式输入回归测试;忽略本机 MCP 数据及 server.json/servers.json。

验证:后端 185 项、前端 54 项测试通过,前端生产构建、相关文件 Ruff 与暂存差异检查通过。
This commit is contained in:
2026-09-03 22:38:35 +08:00
parent 2f7066aa92
commit 9b8b10cdb1
13 changed files with 1097 additions and 80 deletions
+4
View File
@@ -14,6 +14,10 @@ backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交) # 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db* backend/data/*.db*
backend/data/credentials/ backend/data/credentials/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
backend/data/mcp/
server.json
servers.json
# Editors and operating systems # Editors and operating systems
.idea/ .idea/
+51 -2
View File
@@ -10,6 +10,7 @@ import asyncio
import json import json
import os import os
import queue import queue
import re
import signal import signal
import subprocess import subprocess
import threading import threading
@@ -49,6 +50,7 @@ MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024
MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024 MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024
MAX_MCP_TOOLS = 500 MAX_MCP_TOOLS = 500
MAX_MCP_LIST_PAGES = 100 MAX_MCP_LIST_PAGES = 100
_SSE_NEWLINE = re.compile(rb"\r\n?|\n")
class McpBridgeError(RuntimeError): class McpBridgeError(RuntimeError):
@@ -1019,7 +1021,8 @@ class McpBridge:
if status.status == PluginHostState.unhealthy: if status.status == PluginHostState.unhealthy:
raise McpBridgeError( raise McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", "PLUGIN_HOST_UNAVAILABLE",
status.error or "MCP event stream became unavailable during startup.", status.error
or "MCP event stream became unavailable during startup.",
status_code=503, status_code=503,
) )
status.status = PluginHostState.ready status.status = PluginHostState.ready
@@ -1383,12 +1386,58 @@ def _bounded_json_response(response: httpx.Response) -> dict[str, Any]:
return payload return payload
def _bounded_sse_lines(response: httpx.Response):
"""Split UTF-8 lines without httpx.iter_lines()'s unbounded line buffer.
Check each segment before appending it, including partial/no-newline input.
SSE allows LF, CR and CRLF; a CRLF pair can span network chunks.
"""
pending = bytearray()
event_size = 0
skip_lf = False
first_line = True
for chunk in response.iter_bytes():
offset = 0
if skip_lf and chunk:
offset = int(chunk.startswith(b"\n"))
skip_lf = False
for match in _SSE_NEWLINE.finditer(chunk, offset):
start, end = match.span()
segment = memoryview(chunk)[offset:start]
if event_size + len(pending) + len(segment) + 1 > MAX_MCP_MESSAGE_BYTES:
raise McpBridgeError(
"MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large."
)
pending.extend(segment)
line = pending.decode("utf-8", errors="replace")
event_size += len(pending) + 1
pending.clear()
if first_line:
line = line.removeprefix("\ufeff")
first_line = False
if not line:
event_size = 0
yield line
skip_lf = chunk[end - 1 : end] == b"\r" and end == len(chunk)
offset = end
tail = memoryview(chunk)[offset:]
if event_size + len(pending) + len(tail) > MAX_MCP_MESSAGE_BYTES:
raise McpBridgeError(
"MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large."
)
pending.extend(tail)
if pending:
line = pending.decode("utf-8", errors="replace")
yield line.removeprefix("\ufeff") if first_line else line
def _iter_sse(response: httpx.Response): def _iter_sse(response: httpx.Response):
event = "message" event = "message"
event_id: str | None = None event_id: str | None = None
data_lines: list[str] = [] data_lines: list[str] = []
size = 0 size = 0
for line in response.iter_lines(): for line in _bounded_sse_lines(response):
size += len(line.encode("utf-8")) + 1 size += len(line.encode("utf-8")) + 1
if size > MAX_MCP_MESSAGE_BYTES: if size > MAX_MCP_MESSAGE_BYTES:
raise McpBridgeError( raise McpBridgeError(
+126 -10
View File
@@ -9,7 +9,7 @@ import threading
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from uuid import uuid4 from uuid import uuid4
@@ -45,10 +45,22 @@ _SERVER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
_MAX_MCP_SERVERS = 256 _MAX_MCP_SERVERS = 256
class _McpConnectionBackend(PluginBackend):
"""Bridge adapter for the independent server's float timeout contract.
Plugin manifests retain their integer/60-second startup restrictions.
Reusing that validation here used to reject valid 120-second server configs.
"""
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
class _McpServerRecord(McpServerConfig): class _McpServerRecord(McpServerConfig):
"""Validated on-disk representation with defaults for older C.1 records.""" """Validated on-disk representation with defaults for older C.1 records."""
version: int = Field(default=1, ge=1) version: int = Field(default=1, ge=1)
secret_environment_version: Literal[1, 2] = 1
enabled: bool = False enabled: bool = False
approved_digest: str | None = Field( approved_digest: str | None = Field(
default=None, min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$" default=None, min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$"
@@ -102,6 +114,8 @@ class McpServerRegistry:
self._registered: dict[str, list[str]] = {} self._registered: dict[str, list[str]] = {}
self._summaries: dict[str, list[McpToolSummary]] = {} self._summaries: dict[str, list[McpToolSummary]] = {}
self._last_status: dict[str, dict[str, Any]] = {} self._last_status: dict[str, dict[str, Any]] = {}
self._generations: dict[str, object] = {}
self._migrate_environment_secrets()
def list(self) -> list[McpServer]: def list(self) -> list[McpServer]:
with self._lock: with self._lock:
@@ -137,6 +151,7 @@ class McpServerRegistry:
record["url"] = request.url.strip() if request.url else None record["url"] = request.url.strip() if request.url else None
record.update( record.update(
version=1, version=1,
secret_environment_version=2,
enabled=False, enabled=False,
approved_digest=None, approved_digest=None,
tested_digest=None, tested_digest=None,
@@ -163,7 +178,7 @@ class McpServerRegistry:
with self._lock: with self._lock:
previous = self._record(server_id) previous = self._record(server_id)
removed_secret_ids = [ removed_secret_ids = [
self._secret_id(server_id, key, kind) secret_id
for kind, old_keys, new_keys in ( for kind, old_keys, new_keys in (
( (
"environment", "environment",
@@ -176,7 +191,8 @@ class McpServerRegistry:
request.secret_header_keys, request.secret_header_keys,
), ),
) )
for key in set(old_keys) - set(new_keys) for secret_id in self._secret_ids(server_id, old_keys, kind)
- self._secret_ids(server_id, new_keys, kind)
] ]
try: try:
self.credentials.delete_many(removed_secret_ids) self.credentials.delete_many(removed_secret_ids)
@@ -191,6 +207,7 @@ class McpServerRegistry:
record["url"] = request.url.strip() if request.url else None record["url"] = request.url.strip() if request.url else None
record.update( record.update(
version=request.version + 1, version=request.version + 1,
secret_environment_version=2,
enabled=False, enabled=False,
approved_digest=None, approved_digest=None,
tested_digest=None, tested_digest=None,
@@ -210,12 +227,12 @@ class McpServerRegistry:
with self._lock: with self._lock:
record = self._record(server_id) record = self._record(server_id)
secret_ids = [ secret_ids = [
self._secret_id(server_id, key, kind) secret_id
for kind, keys in ( for kind, keys in (
("environment", record.get("secret_environment_keys", [])), ("environment", record.get("secret_environment_keys", [])),
("header", record.get("secret_header_keys", [])), ("header", record.get("secret_header_keys", [])),
) )
for key in keys for secret_id in self._secret_ids(server_id, keys, kind)
] ]
try: try:
self.credentials.delete_many(secret_ids) self.credentials.delete_many(secret_ids)
@@ -307,6 +324,8 @@ class McpServerRegistry:
try: try:
discovered = self._start(server_id, record) discovered = self._start(server_id, record)
except Exception as exc: except Exception as exc:
self._generations.pop(server_id, None)
self.bridge.remove(self._host_id(server_id))
tested_at = datetime.now(UTC) tested_at = datetime.now(UTC)
failure = { failure = {
"status": PluginHostState.error, "status": PluginHostState.error,
@@ -339,6 +358,7 @@ class McpServerRegistry:
"last_test_succeeded": True, "last_test_succeeded": True,
} }
self._summaries[server_id] = self._tool_summaries(discovered) self._summaries[server_id] = self._tool_summaries(discovered)
self._generations.pop(server_id, None)
self.bridge.stop(self._host_id(server_id)) self.bridge.stop(self._host_id(server_id))
with self._lock: with self._lock:
tested_record = { tested_record = {
@@ -368,6 +388,7 @@ class McpServerRegistry:
except Exception: except Exception:
for name in registered: for name in registered:
self.tools.unregister(name) self.tools.unregister(name)
self._generations.pop(server_id, None)
self.bridge.stop(self._host_id(server_id)) self.bridge.stop(self._host_id(server_id))
raise raise
try: try:
@@ -380,6 +401,7 @@ class McpServerRegistry:
except McpRegistryError: except McpRegistryError:
for name in registered: for name in registered:
self.tools.unregister(name) self.tools.unregister(name)
self._generations.pop(server_id, None)
self.bridge.stop(self._host_id(server_id)) self.bridge.stop(self._host_id(server_id))
raise raise
return self.get(server_id) return self.get(server_id)
@@ -394,6 +416,7 @@ class McpServerRegistry:
self._records = updated self._records = updated
for name in self._registered.pop(server_id, []): for name in self._registered.pop(server_id, []):
self.tools.unregister(name) self.tools.unregister(name)
self._generations.pop(server_id, None)
self.bridge.stop(self._host_id(server_id)) self.bridge.stop(self._host_id(server_id))
return self.get(server_id) return self.get(server_id)
@@ -416,6 +439,7 @@ class McpServerRegistry:
@_serialized_lifecycle @_serialized_lifecycle
def shutdown(self) -> None: def shutdown(self) -> None:
for server_id in list(self._records): for server_id in list(self._records):
self._generations.pop(server_id, None)
for name in self._registered.pop(server_id, []): for name in self._registered.pop(server_id, []):
self.tools.unregister(name) self.tools.unregister(name)
self.bridge.stop(self._host_id(server_id)) self.bridge.stop(self._host_id(server_id))
@@ -456,6 +480,9 @@ class McpServerRegistry:
) )
headers[key] = value headers[key] = value
host_id = self._host_id(server_id) host_id = self._host_id(server_id)
# A queued callback from the previous process must not affect its replacement.
generation = object()
self._generations[server_id] = generation
self.bridge.remove(host_id) self.bridge.remove(host_id)
try: try:
return self.bridge.start( return self.bridge.start(
@@ -463,7 +490,9 @@ class McpServerRegistry:
self._backend(record), self._backend(record),
self._server_dir(server_id), self._server_dir(server_id),
list(record.get("permissions", [])), list(record.get("permissions", [])),
lambda _host, message: self._unavailable(server_id, message), lambda _host, message: self._unavailable(
server_id, generation, message
),
command_override=( command_override=(
[record["command"], *record.get("args", [])] [record["command"], *record.get("args", [])]
if record.get("command") if record.get("command")
@@ -476,6 +505,8 @@ class McpServerRegistry:
headers=headers, headers=headers,
) )
except McpBridgeError as exc: except McpBridgeError as exc:
self._generations.pop(server_id, None)
self.bridge.remove(host_id)
raise McpRegistryError( raise McpRegistryError(
exc.code, exc.message, status_code=exc.status_code exc.code, exc.message, status_code=exc.status_code
) from exc ) from exc
@@ -496,10 +527,13 @@ class McpServerRegistry:
self.tools.register(definition, arguments_model, executor) self.tools.register(definition, arguments_model, executor)
def _unavailable(self, server_id: str, message: str) -> None: def _unavailable(self, server_id: str, generation: object, message: str) -> None:
# A failure may race with enable(). Waiting for the lifecycle mutation makes # A failure may race with enable(). Waiting for the lifecycle mutation makes
# sure tools registered immediately before the callback are also removed. # sure tools registered immediately before the callback are also removed.
with self._lifecycle_lock: with self._lifecycle_lock:
if self._generations.get(server_id) is not generation:
return
self._generations.pop(server_id, None)
try: try:
with self._lock: with self._lock:
record = self._records.get(server_id) record = self._records.get(server_id)
@@ -694,7 +728,7 @@ class McpServerRegistry:
@staticmethod @staticmethod
def _backend(record: dict[str, Any]) -> PluginBackend: def _backend(record: dict[str, Any]) -> PluginBackend:
return PluginBackend( return _McpConnectionBackend(
type="mcp", type="mcp",
transport="stdio", transport="stdio",
command=record.get("command") or "http", command=record.get("command") or "http",
@@ -755,9 +789,89 @@ class McpServerRegistry:
@staticmethod @staticmethod
def _secret_id(server_id: str, key: str, kind: str = "environment") -> str: def _secret_id(server_id: str, key: str, kind: str = "environment") -> str:
suffix = hashlib.sha256(f"{kind}\0{key.casefold()}".encode()).hexdigest()[:20] identity = (
f"environment-v2\0{key}"
if kind == "environment"
else f"{kind}\0{key.casefold()}"
)
suffix = hashlib.sha256(identity.encode()).hexdigest()[:20]
return f"mcp.{server_id}.{suffix}" return f"mcp.{server_id}.{suffix}"
@staticmethod
def _legacy_environment_secret_id(server_id: str, key: str) -> str:
suffix = hashlib.sha256(f"environment\0{key.casefold()}".encode()).hexdigest()[
:20
]
return f"mcp.{server_id}.{suffix}"
def _secret_ids(self, server_id: str, keys: list[str], kind: str) -> set[str]:
ids = {self._secret_id(server_id, key, kind) for key in keys}
if kind == "environment":
# Include retained ambiguous legacy ciphertext when its last declaration is removed.
ids.update(
self._legacy_environment_secret_id(server_id, key) for key in keys
)
return ids
def _migrate_environment_secrets(self) -> None:
"""迁移旧的大小写折叠 ID;已碰撞的值无法恢复,保留原密文并要求重新录入。"""
replacements: dict[str, str] = {}
ambiguous: dict[str, list[str]] = {}
legacy_records = {
server_id: record
for server_id, record in self._records.items()
if record.get("secret_environment_version", 1) == 1
}
if not legacy_records:
return
for server_id, record in legacy_records.items():
groups: dict[str, set[str]] = {}
for key in record.get("secret_environment_keys", []):
groups.setdefault(key.casefold(), set()).add(key)
for keys in groups.values():
key = next(iter(keys))
legacy_id = self._legacy_environment_secret_id(server_id, key)
if len(keys) == 1:
replacements[legacy_id] = self._secret_id(server_id, key)
else:
ambiguous.setdefault(server_id, []).extend(keys)
try:
self.credentials.move_many(replacements)
for server_id, keys in ambiguous.items():
if not any(
self.credentials.has(
self._legacy_environment_secret_id(server_id, key)
)
for key in keys
):
continue
if all(
self.credentials.has(self._secret_id(server_id, key))
for key in keys
):
continue
self._records[server_id].update(
enabled=False,
tested_digest=None,
last_test_succeeded=None,
last_tested_at=None,
)
self._last_status[server_id] = {
"status": PluginHostState.error,
"error": "环境变量密钥名称曾发生大小写冲突,请分别重新录入密钥并测试连接。",
}
# Persist a migration marker even when legacy values were ambiguous.
# Otherwise a later key removal could make that old shared value look
# unambiguous and resurrect a deleted credential on the next restart.
for server_id in legacy_records:
self._records[server_id]["secret_environment_version"] = 2
self._write()
except CredentialStoreError as exc:
raise McpRegistryError(
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
def _secret_configured( def _secret_configured(
self, server_id: str, key: str, kind: str = "environment" self, server_id: str, key: str, kind: str = "environment"
) -> bool: ) -> bool:
@@ -857,7 +971,9 @@ class McpServerRegistry:
config_fields = set(McpServerConfig.model_fields) config_fields = set(McpServerConfig.model_fields)
try: try:
for server_id, raw in value.items(): for server_id, raw in value.items():
if not isinstance(server_id, str) or not _SERVER_ID.fullmatch(server_id): if not isinstance(server_id, str) or not _SERVER_ID.fullmatch(
server_id
):
raise ValueError("invalid server id") raise ValueError("invalid server id")
record = _McpServerRecord.model_validate(raw) record = _McpServerRecord.model_validate(raw)
config = record.model_dump(mode="json", include=config_fields) config = record.model_dump(mode="json", include=config_fields)
+35 -9
View File
@@ -5,13 +5,12 @@ import os
import re import re
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import ClassVar, Protocol
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from app.config import get_settings from app.config import get_settings
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") _CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_PLUGIN_CREDENTIAL_PREFIX = "plugin." _PLUGIN_CREDENTIAL_PREFIX = "plugin."
_MCP_CREDENTIAL_PREFIX = "mcp." _MCP_CREDENTIAL_PREFIX = "mcp."
@@ -29,7 +28,9 @@ def validate_provider_credential_id(credential_id: str | None) -> None:
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。""" """阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX): if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX):
raise CredentialStoreError("Credential namespace is reserved for Plugin settings.") raise CredentialStoreError(
"Credential namespace is reserved for Plugin settings."
)
if credential_id and credential_id.casefold().startswith(_MCP_CREDENTIAL_PREFIX): if credential_id and credential_id.casefold().startswith(_MCP_CREDENTIAL_PREFIX):
raise CredentialStoreError("Credential namespace is reserved for MCP settings.") raise CredentialStoreError("Credential namespace is reserved for MCP settings.")
@@ -37,7 +38,7 @@ def validate_provider_credential_id(credential_id: str | None) -> None:
class EnvironmentCredentialResolver: class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
_development_aliases = { _development_aliases: ClassVar[dict[str, str]] = {
"openai": "OPENAI_API_KEY", "openai": "OPENAI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY", "deepseek": "DEEPSEEK_API_KEY",
} }
@@ -85,7 +86,9 @@ class EncryptedCredentialStore:
try: try:
return Fernet(environment_key.encode("ascii")) return Fernet(environment_key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc: except (ValueError, UnicodeEncodeError) as exc:
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc raise CredentialStoreError(
"APP_CREDENTIAL_MASTER_KEY is invalid."
) from exc
key_path.parent.mkdir(parents=True, exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(key_path.parent, 0o700) self._restrict(key_path.parent, 0o700)
@@ -102,7 +105,9 @@ class EncryptedCredentialStore:
try: try:
return Fernet(key_path.read_bytes().strip()) return Fernet(key_path.read_bytes().strip())
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
raise CredentialStoreError("Credential master key cannot be loaded.") from exc raise CredentialStoreError(
"Credential master key cannot be loaded."
) from exc
def _read_tokens(self) -> dict[str, str]: def _read_tokens(self) -> dict[str, str]:
_, store_path = self._paths() _, store_path = self._paths()
@@ -111,11 +116,16 @@ class EncryptedCredentialStore:
try: try:
data = json.loads(store_path.read_text(encoding="utf-8")) data = json.loads(store_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc raise CredentialStoreError(
"Encrypted credential store cannot be loaded."
) from exc
if not isinstance(data, dict) or not all( if not isinstance(data, dict) or not all(
isinstance(key, str) and isinstance(value, str) for key, value in data.items() isinstance(key, str) and isinstance(value, str)
for key, value in data.items()
): ):
raise CredentialStoreError("Encrypted credential store has an invalid format.") raise CredentialStoreError(
"Encrypted credential store has an invalid format."
)
return data return data
def _write_tokens(self, tokens: dict[str, str]) -> None: def _write_tokens(self, tokens: dict[str, str]) -> None:
@@ -196,6 +206,22 @@ class EncryptedCredentialStore:
self._write_tokens(tokens) self._write_tokens(tokens)
return removed return removed
def move_many(self, replacements: dict[str, str]) -> None:
"""原子迁移凭据 ID,直接移动密文且不覆盖已经写入的新凭据。"""
for old_id, new_id in replacements.items():
self._validate_id(old_id)
self._validate_id(new_id)
with self._lock:
tokens = self._read_tokens()
changed = False
for old_id, new_id in replacements.items():
if old_id != new_id and old_id in tokens:
tokens.setdefault(new_id, tokens.pop(old_id))
changed = True
if changed:
self._write_tokens(tokens)
class ChainedCredentialResolver: class ChainedCredentialResolver:
def __init__(self, *resolvers: CredentialResolver) -> None: def __init__(self, *resolvers: CredentialResolver) -> None:
+6 -13
View File
@@ -100,15 +100,8 @@ from app.services import (
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
def mcp_call(operation):
try:
return operation()
except McpRegistryError as exc:
raise ApiError(exc.status_code, exc.code, exc.message) from exc
async def mcp_call_async(operation): async def mcp_call_async(operation):
"""MCP process operations wait on stdio and must not block the API event loop.""" """Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
try: try:
return await asyncio.to_thread(operation) return await asyncio.to_thread(operation)
except McpRegistryError as exc: except McpRegistryError as exc:
@@ -521,19 +514,19 @@ async def uninstall_skill(skill_id: str) -> OperationResponse:
# Independent MCP Server Registry # Independent MCP Server Registry
@router.get("/mcp/servers", response_model=McpServerListResponse, tags=["MCP Servers"]) @router.get("/mcp/servers", response_model=McpServerListResponse, tags=["MCP Servers"])
async def list_mcp_servers() -> McpServerListResponse: async def list_mcp_servers() -> McpServerListResponse:
return McpServerListResponse(items=mcp_call(container.mcp_servers.list)) return McpServerListResponse(items=await mcp_call_async(container.mcp_servers.list))
@router.post( @router.post(
"/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"] "/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"]
) )
async def create_mcp_server(request: McpServerCreateRequest) -> McpServer: async def create_mcp_server(request: McpServerCreateRequest) -> McpServer:
return mcp_call(lambda: container.mcp_servers.create(request)) return await mcp_call_async(lambda: container.mcp_servers.create(request))
@router.get("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"]) @router.get("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
async def get_mcp_server(server_id: str) -> McpServer: async def get_mcp_server(server_id: str) -> McpServer:
return mcp_call(lambda: container.mcp_servers.get(server_id)) return await mcp_call_async(lambda: container.mcp_servers.get(server_id))
@router.get( @router.get(
@@ -543,7 +536,7 @@ async def get_mcp_server(server_id: str) -> McpServer:
) )
async def list_mcp_server_tools(server_id: str) -> McpToolSummaryListResponse: async def list_mcp_server_tools(server_id: str) -> McpToolSummaryListResponse:
return McpToolSummaryListResponse( return McpToolSummaryListResponse(
items=mcp_call(lambda: container.mcp_servers.list_tools(server_id)) items=await mcp_call_async(lambda: container.mcp_servers.list_tools(server_id))
) )
@@ -570,7 +563,7 @@ async def delete_mcp_server(server_id: str) -> OperationResponse:
"/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"] "/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"]
) )
async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer: async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer:
return mcp_call( return await mcp_call_async(
lambda: container.mcp_servers.trust(server_id, request.command_digest) lambda: container.mcp_servers.trust(server_id, request.command_digest)
) )
+180 -6
View File
@@ -2,6 +2,8 @@ import asyncio
import threading import threading
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from app.contracts import ( from app.contracts import (
McpServerSecretStatus, McpServerSecretStatus,
McpServerSecretWriteRequest, McpServerSecretWriteRequest,
@@ -61,9 +63,7 @@ def test_mcp_secret_routes_offload_blocking_lifecycle_work(monkeypatch) -> None:
) )
) )
deleted = asyncio.run( deleted = asyncio.run(
routes.delete_mcp_server_secret( routes.delete_mcp_server_secret("server-1", "TOKEN", kind="environment")
"server-1", "TOKEN", kind="environment"
)
) )
assert written.configured is True assert written.configured is True
@@ -77,6 +77,172 @@ def test_health() -> None:
assert response.model_dump() == {"status": "ok"} assert response.model_dump() == {"status": "ok"}
def test_mcp_create_and_trust_are_not_executed_on_event_loop(monkeypatch) -> None:
from app import routes
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
caller = threading.get_ident()
workers = []
class Registry:
def create(self, request):
workers.append(threading.get_ident())
return "created"
def trust(self, server_id, digest):
workers.append(threading.get_ident())
return "trusted"
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=Registry()))
assert (
asyncio.run(
routes.create_mcp_server(McpServerCreateRequest(name="test", command="uvx"))
)
== "created"
)
assert (
asyncio.run(
routes.trust_mcp_server(
"test", McpServerTrustRequest(command_digest="a" * 64)
)
)
== "trusted"
)
assert len(workers) == 2
assert all(worker != caller for worker in workers)
def test_mcp_split_config_and_secret_requests_persist_without_plaintext(
monkeypatch,
) -> None:
from fastapi.testclient import TestClient
from app import routes
from app.agent.tools import ToolRegistry
from app.config import get_settings
from app.extensions.mcp_registry import McpServerRegistry
from app.main import app
from app.providers.credentials import EncryptedCredentialStore
service = McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
get_settings().data_dir,
allow_process_launch=True,
)
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
client = TestClient(app)
config = {
"name": "MiniMax configuration test",
"command": "uvx",
"environment": {"MINIMAX_API_HOST": "https://api.minimaxi.com"},
"secret_environment_keys": ["MINIMAX_API_KEY"],
"startup_timeout_seconds": 120,
"tool_timeout_seconds": 300,
}
# Reproduce the old frontend payload. The backend still enforces separation.
invalid = client.post(
"/api/mcp/servers",
json={
**config,
"environment": {
**config["environment"],
"MINIMAX_API_KEY": "synthetic-only",
},
},
)
assert invalid.status_code == 422
assert invalid.json()["error"]["code"] == "MCP_ENVIRONMENT_INVALID"
created = client.post("/api/mcp/servers", json=config)
assert created.status_code == 201
server_id = created.json()["server_id"]
saved = client.put(
f"/api/mcp/servers/{server_id}/secrets/MINIMAX_API_KEY",
json={"secret": "synthetic-only"},
)
assert saved.status_code == 200
current = client.get(f"/api/mcp/servers/{server_id}")
assert current.json()["secret_environment"] == {"MINIMAX_API_KEY": True}
assert "synthetic-only" not in current.text
assert "synthetic-only" not in service._path.read_text(encoding="utf-8")
_, credentials_path = service.credentials._paths()
assert "synthetic-only" not in credentials_path.read_text(encoding="utf-8")
assert not current.json()["enabled"] # Saving never starts a third-party process.
client.close()
@pytest.mark.parametrize("operation", ["create", "trust"])
def test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive(
monkeypatch,
operation,
) -> None:
from app import routes
from app.agent.tools import ToolRegistry
from app.config import get_settings
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
from app.extensions.mcp_registry import McpServerRegistry
from app.providers.credentials import EncryptedCredentialStore
service = McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
get_settings().data_dir,
allow_process_launch=True,
)
request = McpServerCreateRequest(
name="Lock contention fixture", command="not-executed"
)
server = service.create(request)
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
entered = threading.Event()
locked = threading.Event()
release = threading.Event()
original = getattr(service, operation)
def observed(*args):
entered.set()
return original(*args)
def hold_lifecycle_lock():
with service._lifecycle_lock:
locked.set()
release.wait(timeout=5)
monkeypatch.setattr(service, operation, observed)
holder = threading.Thread(target=hold_lifecycle_lock, daemon=True)
holder.start()
# An independent watchdog lets the test fail rather than hang if a regression
# blocks the event loop itself (an asyncio timeout alone cannot catch that).
watchdog = threading.Timer(5, release.set)
watchdog.start()
async def exercise():
pending = asyncio.create_task(
routes.create_mcp_server(request)
if operation == "create"
else routes.trust_mcp_server(
server.server_id,
McpServerTrustRequest(command_digest=server.command_digest),
)
)
try:
assert await asyncio.to_thread(entered.wait, 2)
assert not pending.done()
assert not release.is_set()
assert (await health()).status == "ok"
finally:
release.set()
await pending
try:
assert locked.wait(timeout=2)
asyncio.run(exercise())
finally:
release.set()
watchdog.cancel()
holder.join(timeout=2)
def test_service_status() -> None: def test_service_status() -> None:
response = asyncio.run(service_status()) response = asyncio.run(service_status())
@@ -93,7 +259,9 @@ def test_core_collections_are_typed() -> None:
assert notes.items == [] assert notes.items == []
assert notes.page.limit == 20 assert notes.page.limit == 20
assert [skill.manifest.skill_id for skill in skills.items] == ["knowledge-assistant"] assert [skill.manifest.skill_id for skill in skills.items] == [
"knowledge-assistant"
]
assert skills.items[0].status == "ready" assert skills.items[0].status == "ready"
assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"] assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"]
assert plugins.items[0].status == "ready" assert plugins.items[0].status == "ready"
@@ -114,9 +282,15 @@ def test_provider_presets_include_openai_and_deepseek() -> None:
def test_provider_presets_static_route_precedes_provider_id_route() -> None: def test_provider_presets_static_route_precedes_provider_id_route() -> None:
from app.routes import router from app.routes import router
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())] get_paths = [
route.path
for route in router.routes
if "GET" in getattr(route, "methods", set())
]
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}") assert get_paths.index("/api/providers/presets") < get_paths.index(
"/api/providers/{provider_id}"
)
def test_openapi_contains_documented_frontend_interfaces() -> None: def test_openapi_contains_documented_frontend_interfaces() -> None:
+212 -7
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import hashlib
import json import json
import sys import sys
import threading import threading
@@ -111,7 +112,9 @@ def test_update_disables_server_and_revokes_command_trust() -> None:
service.shutdown() service.shutdown()
def test_update_remains_retryable_when_removed_secret_cleanup_fails(monkeypatch) -> None: def test_update_remains_retryable_when_removed_secret_cleanup_fails(
monkeypatch,
) -> None:
service = registry() service = registry()
created = service.create(request()) created = service.create(request())
service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry") service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry")
@@ -166,7 +169,9 @@ def test_unavailable_server_removes_bridge_host(monkeypatch) -> None:
removed: list[str] = [] removed: list[str] = []
monkeypatch.setattr(service.bridge, "remove", removed.append) monkeypatch.setattr(service.bridge, "remove", removed.append)
service._unavailable(created.server_id, "connection lost") generation = object()
service._generations[created.server_id] = generation
service._unavailable(created.server_id, generation, "connection lost")
current = service.get(created.server_id) current = service.get(created.server_id)
assert removed == [f"mcp.{created.server_id}"] assert removed == [f"mcp.{created.server_id}"]
@@ -175,6 +180,180 @@ def test_unavailable_server_removes_bridge_host(monkeypatch) -> None:
service.shutdown() service.shutdown()
def test_old_failure_callback_cannot_stop_replacement_host(monkeypatch) -> None:
service = registry()
callbacks = []
original_start = service.bridge.start
def capture_callback(*args, **kwargs):
callbacks.append(args[4])
return original_start(*args, **kwargs)
monkeypatch.setattr(service.bridge, "start", capture_callback)
created = service.create(request(secret_environment_keys=[]))
service.trust(created.server_id, created.command_digest)
callback_thread = None
try:
service.test(created.server_id)
service.enable(created.server_id)
old_callback = callbacks[-1]
callback_started = threading.Event()
callback_finished = threading.Event()
def delayed_failure():
callback_started.set()
old_callback(f"mcp.{created.server_id}", "delayed old failure")
callback_finished.set()
# Queue the old callback while a replacement owns the lifecycle lock.
with service._lifecycle_lock:
callback_thread = threading.Thread(target=delayed_failure, daemon=True)
callback_thread.start()
assert callback_started.wait(timeout=2)
service.disable(created.server_id)
service.enable(created.server_id)
assert callback_finished.wait(timeout=2)
assert service.get(created.server_id).enabled is True
assert service.get(created.server_id).status == "ready"
assert service.tools.definitions()
callbacks[-1](f"mcp.{created.server_id}", "current failure")
assert service.get(created.server_id).enabled is False
assert service.get(created.server_id).status == "unhealthy"
finally:
service.shutdown()
if callback_thread is not None:
callback_thread.join(timeout=2)
def test_header_case_only_rename_preserves_secret() -> None:
service = registry()
config = {
"name": "HTTP",
"transport": "streamable_http",
"url": "https://example.test/mcp",
"secret_header_keys": ["Authorization"],
}
created = service.create(McpServerCreateRequest(**config))
service.put_secret(created.server_id, "Authorization", "synthetic", kind="header")
config["secret_header_keys"] = ["authorization"]
updated = service.update(
created.server_id, McpServerUpdateRequest(**config, version=created.version)
)
assert updated.secret_headers == {"authorization": True}
assert (
service.credentials.resolve(
service._secret_id(created.server_id, "authorization", "header")
)
== "synthetic"
)
def test_environment_secrets_are_case_sensitive_and_delete_independently() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
service.put_secret(created.server_id, "TOKEN", "upper")
service.put_secret(created.server_id, "token", "lower")
assert (
service.credentials.resolve(service._secret_id(created.server_id, "TOKEN"))
== "upper"
)
assert (
service.credentials.resolve(service._secret_id(created.server_id, "token"))
== "lower"
)
service.delete_secret(created.server_id, "TOKEN")
assert service.get(created.server_id).secret_environment == {
"TOKEN": False,
"token": True,
}
def test_legacy_environment_credential_migration_is_idempotent() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN"]))
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
legacy_id = f"mcp.{created.server_id}.{suffix}"
service.credentials.put(legacy_id, "legacy-value")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
assert migrated.get(created.server_id).secret_environment == {"TOKEN": True}
assert (
migrated.credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
== "legacy-value"
)
assert not migrated.credentials.has(legacy_id)
migrated.put_secret(created.server_id, "TOKEN", "new-value")
assert (
registry().credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
== "new-value"
)
def test_ambiguous_legacy_credentials_are_not_assigned_to_two_variables() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
legacy_id = f"mcp.{created.server_id}.{suffix}"
service.credentials.put(legacy_id, "cannot-reconstruct-originals")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
current = migrated.get(created.server_id)
assert current.secret_environment == {"TOKEN": False, "token": False}
assert current.enabled is False
assert current.last_test_succeeded is None
assert migrated.credentials.has(
legacy_id
) # Keep the original ciphertext recoverable.
migrated.put_secret(created.server_id, "TOKEN", "upper")
migrated.put_secret(created.server_id, "token", "lower")
assert registry().get(created.server_id).secret_environment == {
"TOKEN": True,
"token": True,
}
migrated.delete(created.server_id)
assert not migrated.credentials.has(legacy_id)
def test_credential_id_migration_keeps_new_values_and_is_atomic(monkeypatch) -> None:
credentials = EncryptedCredentialStore()
credentials.put("mcp.old", "old-value")
credentials.put("mcp.new", "new-value")
original_write = credentials._write_tokens
def fail_write(_tokens):
raise CredentialStoreError("synthetic failure")
monkeypatch.setattr(credentials, "_write_tokens", fail_write)
with pytest.raises(CredentialStoreError):
credentials.move_many({"mcp.old": "mcp.new"})
assert credentials.resolve("mcp.old") == "old-value"
assert credentials.resolve("mcp.new") == "new-value"
monkeypatch.setattr(credentials, "_write_tokens", original_write)
credentials.move_many({"mcp.old": "mcp.new"})
assert credentials.resolve("mcp.old") is None
assert credentials.resolve("mcp.new") == "new-value"
def test_ambiguous_legacy_secret_is_not_resurrected_after_removing_a_key() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
legacy_id = service._legacy_environment_secret_id(created.server_id, "TOKEN")
service.credentials.put(legacy_id, "ambiguous-old-value")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
migrated.update(
created.server_id,
McpServerUpdateRequest(
**request(secret_environment_keys=["token"]).model_dump(),
version=created.version,
),
)
assert registry().get(created.server_id).secret_environment == {"token": False}
def test_production_rejects_process_launch_even_after_approval() -> None: def test_production_rejects_process_launch_even_after_approval() -> None:
service = registry(launch=False) service = registry(launch=False)
created = service.create(request(secret_environment_keys=[])) created = service.create(request(secret_environment_keys=[]))
@@ -184,6 +363,29 @@ def test_production_rejects_process_launch_even_after_approval() -> None:
assert error.value.code == "MCP_SANDBOX_REQUIRED" assert error.value.code == "MCP_SANDBOX_REQUIRED"
@pytest.mark.parametrize("startup,tool", [(120, 300), (1.5, 2.5)])
def test_server_timeouts_survive_bridge_adaptation_and_reload(startup, tool) -> None:
service = registry()
created = service.create(
request(
secret_environment_keys=[],
startup_timeout_seconds=startup,
tool_timeout_seconds=tool,
)
)
service.trust(created.server_id, created.command_digest)
try:
tested = service.test(created.server_id)
assert tested.last_test_succeeded is True
assert tested.startup_timeout_seconds == startup
assert tested.tool_timeout_seconds == tool
restored = registry().get(created.server_id)
assert restored.startup_timeout_seconds == startup
assert restored.tool_timeout_seconds == tool
finally:
service.shutdown()
def test_enable_requires_successful_test_and_update_checks_version() -> None: def test_enable_requires_successful_test_and_update_checks_version() -> None:
service = registry() service = registry()
created = service.create(request(secret_environment_keys=[])) created = service.create(request(secret_environment_keys=[]))
@@ -307,15 +509,18 @@ def test_lifecycle_operations_are_serialized_and_tool_names_are_isolated() -> No
service.test(server.server_id) service.test(server.server_id)
with ThreadPoolExecutor(max_workers=4) as pool: with ThreadPoolExecutor(max_workers=4) as pool:
enabled = list(pool.map(lambda item: service.enable(item.server_id), servers * 2)) enabled = list(
pool.map(lambda item: service.enable(item.server_id), servers * 2)
)
assert all(item.enabled for item in enabled) assert all(item.enabled for item in enabled)
names = [ names = [
item.name item.name for item in service.tools.definitions() if item.source == "mcp_server"
for item in service.tools.definitions()
if item.source == "mcp_server"
] ]
assert len(names) == len(set(names)) assert len(names) == len(set(names))
assert all(any(name.startswith(f"mcp.{item.server_id}.") for name in names) for item in servers) assert all(
any(name.startswith(f"mcp.{item.server_id}.") for name in names)
for item in servers
)
with ThreadPoolExecutor(max_workers=4) as pool: with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(lambda item: service.disable(item.server_id), servers * 2)) list(pool.map(lambda item: service.disable(item.server_id), servers * 2))
+83
View File
@@ -0,0 +1,83 @@
import json
from contextlib import closing
import httpx
import pytest
from app.extensions import mcp
class ChunkStream(httpx.SyncByteStream):
def __init__(self, chunks):
self.chunks = chunks
self.bytes_read = 0
def __iter__(self):
for chunk in self.chunks:
self.bytes_read += len(chunk)
yield chunk
def test_sse_rejects_unterminated_line_before_reading_entire_stream(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 1024)
stream = ChunkStream([b"x" * 256] * 256)
with (
closing(httpx.Response(200, stream=stream)) as response,
pytest.raises(mcp.McpBridgeError, match="too large"),
):
list(mcp._iter_sse(response))
assert stream.bytes_read == 1280
def test_sse_limits_combined_event_before_partial_line_is_complete(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 32)
stream = ChunkStream(
[b"data: 123456789\n", b"data: 123456789\n", b"x", b"not-read"]
)
with (
closing(httpx.Response(200, stream=stream)) as response,
pytest.raises(mcp.McpBridgeError, match="too large"),
):
list(mcp._iter_sse(response))
assert stream.bytes_read == 33
@pytest.mark.parametrize("separator", [b"\n", b"\r", b"\r\n"])
@pytest.mark.parametrize("chunk_size", [1, 2, 7, 1024])
def test_sse_preserves_utf8_and_line_endings_across_chunks(separator, chunk_size):
payload = json.dumps(
{"jsonrpc": "2.0", "id": 1, "result": {"text": "中文"}}, ensure_ascii=False
)
wire = b"\xef\xbb\xbf" + separator.join(
[
b": heartbeat",
b"event: message",
b"id: replay-1",
("data: " + payload).encode(),
b"",
b"",
]
)
stream = ChunkStream(
[wire[index : index + chunk_size] for index in range(0, len(wire), chunk_size)]
)
with closing(httpx.Response(200, stream=stream)) as response:
assert list(mcp._iter_sse(response)) == [("message", "replay-1", payload)]
def test_sse_event_limit_resets_between_events(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 16)
with closing(
httpx.Response(200, stream=ChunkStream([b"data: one\n\ndata: two\r\r"]))
) as response:
assert list(mcp._iter_sse(response)) == [
("message", None, "one"),
("message", None, "two"),
]
def test_sse_preserves_multiline_data_and_final_unterminated_line():
with closing(
httpx.Response(200, stream=ChunkStream([b"data: first\ndata: last"]))
) as response:
assert list(mcp._iter_sse(response)) == [("message", None, "first\nlast")]
@@ -19,7 +19,44 @@ Streamable HTTP 按 MCP 当前规范实现;SSE 仅用于兼容旧 Server,不
普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。更新请求必须携带读取到的 `version`;版本过期返回 `409 MCP_SERVER_VERSION_CONFLICT`,避免多个页面互相覆盖。改变 Transport、命令、URL、Header、环境变量或权限后,旧授权和测试结果立即失效。 普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。更新请求必须携带读取到的 `version`;版本过期返回 `409 MCP_SERVER_VERSION_CONFLICT`,避免多个页面互相覆盖。改变 Transport、命令、URL、Header、环境变量或权限后,旧授权和测试结果立即失效。
Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据存储。API 只返回环境变量或 Header 是否配置,绝不返回明文。删除 Server 或移除 Secret 键会同步清理密文。前端密码框提交后立即清空,不写入 JSON 编辑器、localStorage、普通配置或日志 `backend/data/mcp/` 是本机运行数据,包含连接配置、授权状态和第三方进程工作目录,不属于团队共享配置。`.gitignore` 忽略整个目录以及 `server.json``servers.json` 文件名;不得强制添加到 Git。提交前检查暂存文件清单,不要将本地密钥、连接配置或运行数据推送到远程
Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据存储。查询 API 只返回环境变量或 Header 是否配置,不返回明文。删除 Server 或移除 Secret 键会同步清理密文。前端密码框提交后立即清空;用户主动粘贴到 JSON 的密钥仅在当前编辑会话中暂存,解析后从 JSON 中移除,不写入 localStorage、普通配置或日志。
环境变量的凭据 ID 使用区分大小写的 v2 名称规则,Header ID 保持大小写不敏感。更新配置按凭据 ID 的差集删除密文,因此 `Authorization` 改为 `authorization` 不会丢失认证信息。启动时对无歧义的旧环境变量凭据原子迁移密文,不覆盖新 ID 已有的值;若旧配置把 `TOKEN``token` 合并存到了同一个 ID,无法推断原来的两个值,会保留旧密文、停用连接并要求重新录入和测试。删除服务器时也会清理这些保留的旧密文。
### 2.1 JSON 导入与 API Key 填写
前端支持 NotesAgent 完整/精简配置、单个 `command / args / env` 配置,以及只含一个服务器的 `mcpServers` 包装。JSON 与表单之间切换会补齐数组、对象及超时默认值,并校验字段类型。批量导入暂不支持;后端配置接口仍只接收 NotesAgent DTO,兼容转换发生在前端。
可以先声明 `secret_environment_keys`,保存后在服务器卡片的密码框填写密钥;也可以把密钥放进 JSON 的 `environment` 或通用配置的 `env`。前端会将已声明的敏感变量,以及名称含 API Key、Token、Secret、Password、Authorization、Cookie、Credential 的常见字段拆出:普通配置请求只包含键名,密钥另经 Secret API 加密保存。其他敏感字段必须显式声明,不能只依赖名称识别;命令与参数中不要携带密钥。
例如 MiniMax 的输入结构如下,占位值需在自己的本地页面替换,不要把真实密钥贴进聊天或提交到 Git:
```json
{
"name": "MiniMax Coding Plan",
"command": "uvx",
"args": ["--index-url", "https://pypi.tuna.tsinghua.edu.cn/simple", "--with", "mcp<2", "minimax-coding-plan-mcp", "-y"],
"environment": {
"MINIMAX_API_HOST": "https://api.minimaxi.com",
"MINIMAX_API_KEY": "<在本地填入新密钥>"
},
"secret_environment_keys": ["MINIMAX_API_KEY"],
"startup_timeout_seconds": 120,
"tool_timeout_seconds": 300
}
```
旧版前端将 `environment.MINIMAX_API_KEY``secret_environment_keys` 原样一起发送,触发后端“普通与敏感变量不可同名”的校验。这是配置保存失败,不是模型服务返回的鉴权失败。现在在前端拆分两类请求,后端仍保留互斥校验。
导入兼容规则:`env` 转为 `environment``timeout` 作为启动超时;`sse_read_timeout` 作为工具等待上限,不保留其原客户端 SSE 读取超时语义。启动超时范围为 1–120 秒,工具超时为 1–300 秒。URL 必须是纯地址,不能使用 Markdown 链接,JSON 中不能包含 `\_` 这样的非法转义。
另一个已修复的失败原因是运行时适配层复用了 `PluginBackend` 的整数超时与 60 秒启动上限,导致合法的 120 秒或小数超时配置在保存返回、读取或测试时失败。独立 Server 现在使用专门的 Bridge 适配模型,保留自己的浮点超时范围,不改变 Plugin 清单原有约束。已有的 120 秒记录可直接读取,无需删库重建。
配置保存成功但后续 Secret 写入失败时,窗口保留服务器 ID、新版本和未写入的密钥。点击保存会更新同一服务器并重试,不重复创建记录;取消会清除未保存密钥,已经保存的服务器和凭据不会回滚。错误信息显示在配置窗口内。保存配置不会自动运行第三方进程,仍需确认、测试和启用。
暂存的 Header Secret 与已保存凭据使用一致的大小写规则:将 `Authorization` 改为 `authorization` 不会丢弃尚未保存的值,提交时采用当前声明名。重新输入同一 Header 的值会覆盖旧草稿;真正删除声明才清除草稿。环境变量仍区分大小写,不会把 `TOKEN` 的草稿转交给 `token`
跨 Registry 与凭据存储的删除以“失败后可重试”为顺序约束:先原子清理密文,再提交新版本或删除 Registry 记录。凭据存储失败时保留原版本和 Server 记录,避免出现返回 500 但配置已提交、版本无法重试或密文失去清理入口的状态。 跨 Registry 与凭据存储的删除以“失败后可重试”为顺序约束:先原子清理密文,再提交新版本或删除 Registry 记录。凭据存储失败时保留原版本和 Server 记录,避免出现返回 500 但配置已提交、版本无法重试或密文失去清理入口的状态。
@@ -34,12 +71,16 @@ Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据
3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 Tool; 3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 Tool;
4. 停用、删除、超时或异常退出会注销 Tool 并关闭连接。 4. 停用、删除、超时或异常退出会注销 Tool 并关闭连接。
运行期连接异常或 Tool 列表变化时,Registry 会在同一生命周期临界区内标记不可用、注销 Tool,并从 Bridge 移除 Host,不保留后台事件线程或 HTTP Client。Secret 写入和删除可能触发该停用流程,因此对应 API 通过工作线程执行,不阻塞 FastAPI 事件循环 运行期连接异常或 Tool 列表变化时,Registry 会在同一生命周期临界区内标记不可用、注销 Tool,并从 Bridge 移除 Host。每次启动分配独立的连接代次;失败回调取得锁后先核对代次,旧连接延迟到达的回调不能停用新连接。停用、测试结束及关闭服务时撤销对应代次
所有独立 MCP API 都通过工作线程执行,包括新增、确认授权和读取接口。虽然部分操作不直接访问网络,但仍可能等待正在测试或启动的连接持有的锁,不能在 FastAPI 事件循环上同步等待。
HTTP Header 中 `Host``Content-Type``MCP-Session-Id` 等协议保留项不可由配置覆盖。URL 不允许内嵌凭据或 Fragment。旧 SSE 返回的 POST endpoint 必须与初始 URL 同源,防止认证 Header 被转发到其他站点。 HTTP Header 中 `Host``Content-Type``MCP-Session-Id` 等协议保留项不可由配置覆盖。URL 不允许内嵌凭据或 Fragment。旧 SSE 返回的 POST endpoint 必须与初始 URL 同源,防止认证 Header 被转发到其他站点。
启动与 Tool 请求超时会同时应用于业务等待和底层 HTTP 请求;旧 SSE 的 endpoint 等待也使用启动超时。非主动结束的旧 SSE 事件流视为 Host 不可用,宿主随后注销 Tool。注册表加载时逐条校验 Server ID、配置字段、Transport 组合和摘要格式,损坏记录统一返回 `MCP_REGISTRY_INVALID` 启动与 Tool 请求超时会同时应用于业务等待和底层 HTTP 请求;旧 SSE 的 endpoint 等待也使用启动超时。非主动结束的旧 SSE 事件流视为 Host 不可用,宿主随后注销 Tool。注册表加载时逐条校验 Server ID、配置字段、Transport 组合和摘要格式,损坏记录统一返回 `MCP_REGISTRY_INVALID`
两种 HTTP Transport 共用有界 SSE 行解析器:按响应字节块检查未完成行及当前事件的累计大小,再扩展缓冲区,不依赖 `iter_lines()` 先缓存完整行。持续无换行输入也会及时触发上限;解析兼容跨块 UTF-8、首行 BOM、LF/CR/CRLF、多行 data 和事件间计数重置。`tests/test_mcp_sse_limits.py` 覆盖这些边界,防止仅在完整行生成后检查大小。
stdio 命令不经过 Shell,管道、重定向和命令拼接不会被解释。Windows 使用新进程组并通过 `taskkill /T` 回收子树;POSIX 使用独立 session/process group 并向进程组发信号。Python 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。 stdio 命令不经过 Shell,管道、重定向和命令拼接不会被解释。Windows 使用新进程组并通过 `taskkill /T` 回收子树;POSIX 使用独立 session/process group 并向进程组发信号。Python 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。
`uvx` 模板使用 `--isolated`、明确的 `--from` 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 `403 MCP_SANDBOX_REQUIRED`;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。 `uvx` 模板使用 `--isolated`、明确的 `--from` 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 `403 MCP_SANDBOX_REQUIRED`;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。
@@ -75,10 +116,26 @@ npm test
npm run build npm run build
``` ```
后端测试使用无需网络或密钥的 stdio Fixture,以及 `httpx.MockTransport` 驱动的确定性 HTTP/SSE Server Fixture。覆盖摘要授权、Secret 不回显、版本冲突、生产门禁、重启恢复、Streamable HTTP Session/Header/工具调用及旧 SSE 同源校验。前端覆盖模板切换、JSON 校验、Secret 请求期输入、测试失败和删除确认 后端测试使用无需网络或真实密钥的 stdio Fixture,以及 `httpx.MockTransport` 驱动的确定性 HTTP/SSE Server Fixture。覆盖摘要授权、Secret 不回显、版本冲突、生产门禁、重启恢复、Streamable HTTP Session/Header/工具调用及旧 SSE 同源校验;新增覆盖旧回调隔离、路由线程卸载、凭据大小写差异与旧密文迁移。前端覆盖模板切换、JSON 默认值与格式兼容、明文拆分、模式切换、部分保存失败重试、取消清理、测试失败和删除确认。此处的 MiniMax 配置转换测试使用假密钥,不等同于真实 MiniMax 网络调用验证
## 6. 后续边界 ## 6. 后续边界
### 本轮 P1/P2 修复验收
| 审阅问题 | 修复方式 | 回归验证 |
| --- | --- | --- |
| P1:新增或授权等待生命周期锁时阻塞事件循环 | 独立 MCP 路由统一交给工作线程 | `test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive`:分别阻塞 create/trust,在锁释放前仍能执行健康检查 |
| P2:旧失败回调误停新连接 | 回调在锁内核对连接代次 | `test_old_failure_callback_cannot_stop_replacement_host`:旧回调排队期间重启连接,释放锁后新连接仍可用,当前代次的失败仍正确停用 |
| P2:精简 JSON 切换模式或编辑保存报错 | 运行时校验、默认值补全、统一转换 | `configuration.spec.ts``McpServersView.spec.ts`:精简 JSON、编辑版本、模式切换和 Secret 保存重试 |
| P2:Header 大小写改名删除凭据 | 按规范化凭据 ID 而非原始键名计算差集 | `test_header_case_only_rename_preserves_secret` |
| P2:大小写不同的环境变量覆盖同一凭据 | 区分大小写的 v2 ID,带迁移标记的旧密文迁移 | `test_environment_secrets_are_case_sensitive_and_delete_independently` 及 legacy migration 测试,包含删除后不复活旧密钥 |
| P1:SSE 无换行输入在大小校验前无限缓冲 | 在行拼接前校验字节数与事件累计大小 | `test_mcp_sse_limits.py`,包括小块持续输入和跨块换行 |
| P2:Header 大小写改名丢失未保存密钥 | 草稿使用规范化名称匹配,并重新绑定当前声明名 | `configuration.spec.ts` 与页面保存回归测试 |
这些修复不放宽 stdio 的 JSON-RPC 校验。第三方程序向 stdout 打印普通日志造成的握手失败,应由服务端调整输出或使用不打印日志的启动入口处理。
### 后续工作
- 增加真实第三方 Server 的兼容矩阵;确定性 Fixture 只能证明宿主协议行为,不能代表所有实现兼容; - 增加真实第三方 Server 的兼容矩阵;确定性 Fixture 只能证明宿主协议行为,不能代表所有实现兼容;
- C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract; - C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract;
- 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。 - 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。
@@ -92,4 +92,78 @@ describe('McpServersView', () => {
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效')) expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
expect(service.updateMcpServer).toHaveBeenCalled() expect(service.updateMcpServer).toHaveBeenCalled()
}) })
it('saves an environment API key via the encrypted endpoint, not the config body', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server' })
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', environment: { MINIMAX_API_KEY: 'synthetic-only' }, secret_environment_keys: ['MINIMAX_API_KEY'] }))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ environment: {}, secret_environment_keys: ['MINIMAX_API_KEY'] }))
expect(JSON.stringify(vi.mocked(service.createMcpServer).mock.calls)).not.toContain('synthetic-only')
expect(service.putMcpServerSecret).toHaveBeenCalledWith('new-server', 'MINIMAX_API_KEY', 'synthetic-only', 'environment')
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
it('retains imported keys over mode switches and retries partial saves without duplicates', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 1 })
vi.mocked(service.updateMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 2 })
vi.mocked(service.putMcpServerSecret).mockRejectedValueOnce(new Error('credential store unavailable')).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', env: { API_KEY: 'retry-value' } }))
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
expect(wrapper.text()).toContain('已识别 1 项密钥')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('retry-value')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
it('clears staged keys on cancel and accepts minimal JSON while editing', async () => {
const wrapper = await render([server])
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue('{"command":"uvx","env":{"API_KEY":"cancelled-value"}}')
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '取消')!.trigger('click')
await wrapper.findAll('button').find(button => button.text().includes('编辑'))!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue('{"name":"Minimal","url":"https://example.test/mcp"}')
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
expect(service.putMcpServerSecret).not.toHaveBeenCalled()
})
it('saves an imported Header secret after a case-only declaration rename', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, secret_headers: { authorization: false } })
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ url: 'https://example.test/mcp', headers: { Authorization: 'synthetic-draft' } }))
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
await wrapper.get('textarea[placeholder="Authorization"]').setValue('authorization')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
expect(wrapper.text()).toContain('已识别 1 项密钥')
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('synthetic-draft')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ headers: {}, secret_header_keys: ['authorization'] }))
expect(service.putMcpServerSecret).toHaveBeenCalledWith('server-1', 'authorization', 'synthetic-draft', 'header')
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
}) })
+64 -30
View File
@@ -4,8 +4,7 @@ import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts' import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
import * as service from '@/services/mcpServerService' import * as service from '@/services/mcpServerService'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
type SecretKind = 'environment' | 'header'
const servers = ref<McpServer[]>([]) const servers = ref<McpServer[]>([])
const busy = ref('') const busy = ref('')
@@ -22,18 +21,11 @@ const secretHeaderKeysText = ref('')
const permissionsText = ref('') const permissionsText = ref('')
const rawConfig = ref('') const rawConfig = ref('')
const secretDrafts = reactive<Record<string, string>>({}) const secretDrafts = reactive<Record<string, string>>({})
const form = reactive<McpServerInput>(emptyForm()) const form = reactive<McpServerInput>(emptyMcpConfig())
const importedSecrets = ref<ImportedSecret[]>([])
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器') const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
function emptyForm(): McpServerInput {
return {
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
}
}
async function load() { async function load() {
error.value = '' error.value = ''
try { servers.value = await service.listMcpServers() } try { servers.value = await service.listMcpServers() }
@@ -41,7 +33,7 @@ async function load() {
} }
function resetEditor(input: McpServerInput) { function resetEditor(input: McpServerInput) {
Object.assign(form, input) Object.assign(form, emptyMcpConfig(), { version: undefined }, input)
argsText.value = input.args.join('\n') argsText.value = input.args.join('\n')
environmentText.value = JSON.stringify(input.environment, null, 2) environmentText.value = JSON.stringify(input.environment, null, 2)
headersText.value = JSON.stringify(input.headers, null, 2) headersText.value = JSON.stringify(input.headers, null, 2)
@@ -53,13 +45,19 @@ function resetEditor(input: McpServerInput) {
} }
function openCreate() { function openCreate() {
if (busy.value) return
error.value = ''
importedSecrets.value = []
editingId.value = null editingId.value = null
editingOriginal.value = null editingOriginal.value = null
resetEditor(emptyForm()) resetEditor(emptyMcpConfig())
dialogOpen.value = true dialogOpen.value = true
} }
function openEdit(server: McpServer) { function openEdit(server: McpServer) {
if (busy.value) return
error.value = ''
importedSecrets.value = []
editingId.value = server.server_id editingId.value = server.server_id
editingOriginal.value = server editingOriginal.value = server
resetEditor({ resetEditor({
@@ -109,38 +107,69 @@ function formPayload(): McpServerInput {
} }
} }
function payload(): McpServerInput { function payload(requireConnection = true): McpServerInput {
if (editorMode.value === 'form') return formPayload() const { config, secrets } = editorMode.value === 'form'
let parsed: unknown ? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
try { parsed = JSON.parse(rawConfig.value) } catch { throw new Error('服务器配置不是有效 JSON') } // Keep only still-declared drafts. A mode switch must not discard imported keys,
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('服务器配置必须是 JSON 对象') // and editing the declaration must not later send a removed key to the Secret API.
const value = parsed as McpServerInput importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
if (editingId.value) value.version = form.version if (editingId.value) config.version = form.version
return value if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
else {
environmentText.value = JSON.stringify(config.environment, null, 2)
headersText.value = JSON.stringify(config.headers, null, 2)
secretKeysText.value = config.secret_environment_keys.join('\n')
secretHeaderKeysText.value = config.secret_header_keys.join('\n')
}
return config
} }
function switchMode(mode: 'form' | 'json') { function switchMode(mode: 'form' | 'json') {
try { try {
if (mode === editorMode.value) return if (mode === editorMode.value) return
if (mode === 'json') rawConfig.value = JSON.stringify(formPayload(), null, 2) error.value = ''
else resetEditor(payload()) if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
else resetEditor(payload(false))
editorMode.value = mode editorMode.value = mode
} catch (cause) { error.value = message(cause, '配置转换失败') } } catch (cause) { error.value = message(cause, '配置转换失败') }
} }
async function save() { async function save() {
if (busy.value) return
let saved: McpServer | undefined
try { try {
error.value = ''
const input = payload() const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址') if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
busy.value = 'save' busy.value = 'save'
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input) saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
dialogOpen.value = false // Commit the returned ID/version before saving secrets so a partial failure can
// retry this server instead of creating a duplicate or sending a stale version.
editingId.value = saved.server_id
editingOriginal.value = saved
resetEditor({ ...input, version: saved.version })
for (const item of [...importedSecrets.value]) {
await service.putMcpServerSecret(saved.server_id, item.key, item.value, item.kind)
importedSecrets.value = importedSecrets.value.filter(candidate => candidate !== item)
}
closeEditor()
await load() await load()
} catch (cause) { error.value = message(cause, '保存失败') } } catch (cause) {
if (saved) await load()
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
}
finally { busy.value = '' } finally { busy.value = '' }
} }
function closeEditor() {
importedSecrets.value = []
rawConfig.value = ''
environmentText.value = '{}'
headersText.value = '{}'
dialogOpen.value = false
}
function executionChanged(server: McpServer, input: McpServerInput) { function executionChanged(server: McpServer, input: McpServerInput) {
const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right)) const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
const current = [ const current = [
@@ -212,9 +241,12 @@ onMounted(load)
</article> </article>
</div> </div>
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false"> <div v-if="dialogOpen" class="modal-backdrop" @click.self="!busy && closeEditor()">
<form class="modal-card" @submit.prevent="save"> <form class="modal-card" @submit.prevent="save">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="dialogOpen = false">×</button></header> <fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div> <div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
<template v-if="editorMode === 'form'"> <template v-if="editorMode === 'form'">
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label> <label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
@@ -224,14 +256,16 @@ onMounted(load)
<label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label> <label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div> <div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template> </template>
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>Secret 只填写键名明文请在保存后的服务器卡片中单独录入</small></label> <label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置command/args/env 和单服务器 mcpServers 配置已声明的 Secret 及常见 API KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</small><small>兼容导入 timeout 为启动超时sse_read_timeout 为工具等待上限不保留 SSE 读取超时语义</small></label>
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer> <footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
</fieldset>
</form> </form>
</div> </div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.editor-fields { display: grid; gap: var(--space-lg); border: 0; padding: 0; margin: 0; min-width: 0; }
.mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); } .mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; } .server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; } .card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson } from './configuration'
describe('MCP configuration normalization', () => {
it('retains renamed HTTP drafts with the latest spelling and value', () => {
const config = { ...emptyMcpConfig(), secret_header_keys: ['authorization'] }
const previous = [{ kind: 'header' as const, key: 'Authorization', value: 'old-value' }]
expect(mergeImportedSecrets(config, previous, [])).toEqual([{ kind: 'header', key: 'authorization', value: 'old-value' }])
expect(mergeImportedSecrets(config, previous, [{ kind: 'header', key: 'AUTHORIZATION', value: 'new-value' }])).toEqual([{ kind: 'header', key: 'authorization', value: 'new-value' }])
expect(mergeImportedSecrets(emptyMcpConfig(), previous, [])).toEqual([])
})
it('does not transfer an environment draft across a case-only rename', () => {
const config = { ...emptyMcpConfig(), secret_environment_keys: ['TOKEN', 'token'] }
const previous = [{ kind: 'environment' as const, key: 'TOKEN', value: 'upper' }, { kind: 'environment' as const, key: 'token', value: 'lower' }]
expect(mergeImportedSecrets(config, previous, [])).toEqual(previous)
expect(mergeImportedSecrets({ ...config, secret_environment_keys: ['token'] }, [previous[0]!], [])).toEqual([])
})
it('fills backend defaults for minimal JSON', () => {
const { config } = parseMcpJson('{"name":"demo","command":"uvx"}')
expect(config).toMatchObject({ transport: 'stdio', args: [], headers: {}, environment: {}, permissions: [], secret_header_keys: [] })
})
it('extracts a key pasted into environment despite its existing secret declaration', () => {
const { config, secrets } = normalizeMcpConfig({
name: 'MiniMax', command: 'uvx', secret_environment_keys: ['MINIMAX_API_KEY'],
environment: { MINIMAX_API_KEY: 'synthetic-key', MINIMAX_API_HOST: 'https://api.minimaxi.com' },
})
expect(config.environment).toEqual({ MINIMAX_API_HOST: 'https://api.minimaxi.com' })
expect(config.secret_environment_keys).toEqual(['MINIMAX_API_KEY'])
expect(JSON.stringify(config)).not.toContain('synthetic-key')
expect(secrets).toEqual([{ kind: 'environment', key: 'MINIMAX_API_KEY', value: 'synthetic-key' }])
})
it('imports a standard single-server wrapper and legacy timeouts', () => {
const { config, secrets } = normalizeMcpConfig({ mcpServers: { MiniMax: {
command: 'uvx', args: ['--with', 'mcp<2', 'minimax-coding-plan-mcp', '-y'],
env: { MINIMAX_API_KEY: 'synthetic-key' }, timeout: 120, sse_read_timeout: 300,
} } })
expect(config).toMatchObject({ name: 'MiniMax', transport: 'stdio', environment: {}, startup_timeout_seconds: 120, tool_timeout_seconds: 300 })
expect(secrets).toHaveLength(1)
})
it('extracts case-insensitive HTTP credentials without duplicate declarations', () => {
const { config, secrets } = normalizeMcpConfig({ url: 'https://example.test/mcp', headers: { authorization: 'synthetic' }, secret_header_keys: ['Authorization'] })
expect(config.headers).toEqual({})
expect(config.secret_header_keys).toEqual(['Authorization'])
expect(secrets[0]?.key).toBe('Authorization')
})
it.each([
[{ command: 'uvx', args: 'not-array' }, 'args'],
[{ command: 'uvx', environment: [] }, 'environment'],
[{ command: 'uvx', timeout: 121 }, '启动超时'],
[{ command: 'uvx', args: ['[https://example.test](https://example.test)'] }, '纯 URL'],
[{ command: 'uvx', api_key: 'do-not-echo' }, '顶层'],
[{ command: 'uvx', env: {}, environment: {} }, '只保留一个'],
[{ mcpServers: { one: {}, two: {} } }, '一次导入一个'],
])('rejects invalid fields without leaking their values', (input, hint) => {
expect(() => normalizeMcpConfig(input)).toThrow(hint)
try { normalizeMcpConfig(input) } catch (error) { expect(String(error)).not.toContain('do-not-echo') }
})
})
+139
View File
@@ -0,0 +1,139 @@
import type { McpServerInput } from '@/contracts'
export type SecretKind = 'environment' | 'header'
export interface ImportedSecret { kind: SecretKind; key: string; value: string }
export function mergeImportedSecrets(config: McpServerInput, previous: ImportedSecret[], incoming: ImportedSecret[]): ImportedSecret[] {
const merged = new Map<string, ImportedSecret>()
for (const item of [...previous, ...incoming]) {
const normalize = (key: string) => item.kind === 'header' ? key.toLowerCase() : key
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
const declared = keys.find(key => normalize(key) === normalize(item.key))
if (declared === undefined) continue
// HTTP identity is case-insensitive, but the Secret API requires the current
// declared spelling. New inline values replace older drafts of that identity.
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
}
return [...merged.values()]
}
export function emptyMcpConfig(): McpServerInput {
return {
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`)
return value as Record<string, unknown>
}
function strings(value: unknown, label: string): string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`)
return [...value]
}
function entries(value: unknown, label: string): Record<string, string> {
if (value === undefined) return {}
const result = object(value, label)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
return { ...result } as Record<string, string>
}
function timeout(value: unknown, fallback: number, max: number, label: string): number {
if (value === undefined) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1${max} 秒之间的数字`)
return value
}
// Do not silently rewrite executable arguments or secret values copied from chat.
function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`)
}
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
let parsed: unknown
try { parsed = JSON.parse(raw) }
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') }
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
}
/** Normalize external client JSON before it reaches either the form or the API.
* Inline secrets leave the public config here and are sent only to the Secret API.
*/
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, '服务器配置')
if ('mcpServers' in raw) {
const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器')
fallbackName = servers[0]![0]
raw = object(servers[0]![1], '服务器配置')
}
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层')
}
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置')
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse')
const config = emptyMcpConfig()
config.transport = transport as McpServerInput['transport']
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器'))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 180 个字符')
config.name = name.trim()
for (const key of ['command', 'url'] as const) {
const value = raw[key]
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`)
config[key] = typeof value === 'string' ? value.trim() : null
}
config.args = strings(raw.args, 'args')
if (config.args.length > 64) throw new Error('args 最多允许 64 项')
for (const value of config.args) checkUrl(value, 'args 中的地址')
config.environment = entries(raw.environment ?? raw.env, 'environment/env')
config.headers = entries(raw.headers, 'headers')
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时')
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时')
if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command')
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header')
} else {
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url')
if (config.url) {
checkUrl(config.url, 'url')
let url: URL
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址')
}
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量')
}
const secrets: ImportedSecret[] = []
for (const kind of ['environment', 'header'] as const) {
const values = kind === 'environment' ? config.environment : config.headers
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
const allKeys = [...Object.keys(values), ...keys]
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明')
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`)
for (const [key, value] of Object.entries(values)) {
const declared = keys.find(item => identity(item) === identity(key))
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
if (declared || sensitive) {
if (!value || value.length > 32768) throw new Error('密钥值必须为 132768 个字符')
const secretKey = declared ?? key
if (!declared) keys.push(key)
secrets.push({ kind, key: secretKey, value })
delete values[key]
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址')
}
}
return { config, secrets }
}