feat(mcp): 完善独立 MCP 服务器配置中心与连接生命周期管理 #13
@@ -14,6 +14,10 @@ backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
|
||||
backend/data/mcp/
|
||||
server.json
|
||||
servers.json
|
||||
|
||||
# Editors and operating systems
|
||||
.idea/
|
||||
|
||||
@@ -10,6 +10,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -49,6 +50,7 @@ MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024
|
||||
MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024
|
||||
MAX_MCP_TOOLS = 500
|
||||
MAX_MCP_LIST_PAGES = 100
|
||||
_SSE_NEWLINE = re.compile(rb"\r\n?|\n")
|
||||
|
||||
|
||||
class McpBridgeError(RuntimeError):
|
||||
@@ -1019,7 +1021,8 @@ class McpBridge:
|
||||
if status.status == PluginHostState.unhealthy:
|
||||
raise McpBridgeError(
|
||||
"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.status = PluginHostState.ready
|
||||
@@ -1383,12 +1386,58 @@ def _bounded_json_response(response: httpx.Response) -> dict[str, Any]:
|
||||
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):
|
||||
event = "message"
|
||||
event_id: str | None = None
|
||||
data_lines: list[str] = []
|
||||
size = 0
|
||||
for line in response.iter_lines():
|
||||
for line in _bounded_sse_lines(response):
|
||||
size += len(line.encode("utf-8")) + 1
|
||||
if size > MAX_MCP_MESSAGE_BYTES:
|
||||
raise McpBridgeError(
|
||||
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from datetime import UTC, datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlsplit
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Validated on-disk representation with defaults for older C.1 records."""
|
||||
|
||||
version: int = Field(default=1, ge=1)
|
||||
secret_environment_version: Literal[1, 2] = 1
|
||||
enabled: bool = False
|
||||
approved_digest: str | None = Field(
|
||||
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._summaries: dict[str, list[McpToolSummary]] = {}
|
||||
self._last_status: dict[str, dict[str, Any]] = {}
|
||||
self._generations: dict[str, object] = {}
|
||||
self._migrate_environment_secrets()
|
||||
|
||||
def list(self) -> list[McpServer]:
|
||||
with self._lock:
|
||||
@@ -137,6 +151,7 @@ class McpServerRegistry:
|
||||
record["url"] = request.url.strip() if request.url else None
|
||||
record.update(
|
||||
version=1,
|
||||
secret_environment_version=2,
|
||||
enabled=False,
|
||||
approved_digest=None,
|
||||
tested_digest=None,
|
||||
@@ -163,7 +178,7 @@ class McpServerRegistry:
|
||||
with self._lock:
|
||||
previous = self._record(server_id)
|
||||
removed_secret_ids = [
|
||||
self._secret_id(server_id, key, kind)
|
||||
secret_id
|
||||
for kind, old_keys, new_keys in (
|
||||
(
|
||||
"environment",
|
||||
@@ -176,7 +191,8 @@ class McpServerRegistry:
|
||||
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:
|
||||
self.credentials.delete_many(removed_secret_ids)
|
||||
@@ -191,6 +207,7 @@ class McpServerRegistry:
|
||||
record["url"] = request.url.strip() if request.url else None
|
||||
record.update(
|
||||
version=request.version + 1,
|
||||
secret_environment_version=2,
|
||||
enabled=False,
|
||||
approved_digest=None,
|
||||
tested_digest=None,
|
||||
@@ -210,12 +227,12 @@ class McpServerRegistry:
|
||||
with self._lock:
|
||||
record = self._record(server_id)
|
||||
secret_ids = [
|
||||
self._secret_id(server_id, key, kind)
|
||||
secret_id
|
||||
for kind, keys in (
|
||||
("environment", record.get("secret_environment_keys", [])),
|
||||
("header", record.get("secret_header_keys", [])),
|
||||
)
|
||||
for key in keys
|
||||
for secret_id in self._secret_ids(server_id, keys, kind)
|
||||
]
|
||||
try:
|
||||
self.credentials.delete_many(secret_ids)
|
||||
@@ -307,6 +324,8 @@ class McpServerRegistry:
|
||||
try:
|
||||
discovered = self._start(server_id, record)
|
||||
except Exception as exc:
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.remove(self._host_id(server_id))
|
||||
tested_at = datetime.now(UTC)
|
||||
failure = {
|
||||
"status": PluginHostState.error,
|
||||
@@ -339,6 +358,7 @@ class McpServerRegistry:
|
||||
"last_test_succeeded": True,
|
||||
}
|
||||
self._summaries[server_id] = self._tool_summaries(discovered)
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.stop(self._host_id(server_id))
|
||||
with self._lock:
|
||||
tested_record = {
|
||||
@@ -368,6 +388,7 @@ class McpServerRegistry:
|
||||
except Exception:
|
||||
for name in registered:
|
||||
self.tools.unregister(name)
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.stop(self._host_id(server_id))
|
||||
raise
|
||||
try:
|
||||
@@ -380,6 +401,7 @@ class McpServerRegistry:
|
||||
except McpRegistryError:
|
||||
for name in registered:
|
||||
self.tools.unregister(name)
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.stop(self._host_id(server_id))
|
||||
raise
|
||||
return self.get(server_id)
|
||||
@@ -394,6 +416,7 @@ class McpServerRegistry:
|
||||
self._records = updated
|
||||
for name in self._registered.pop(server_id, []):
|
||||
self.tools.unregister(name)
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.stop(self._host_id(server_id))
|
||||
return self.get(server_id)
|
||||
|
||||
@@ -416,6 +439,7 @@ class McpServerRegistry:
|
||||
@_serialized_lifecycle
|
||||
def shutdown(self) -> None:
|
||||
for server_id in list(self._records):
|
||||
self._generations.pop(server_id, None)
|
||||
for name in self._registered.pop(server_id, []):
|
||||
self.tools.unregister(name)
|
||||
self.bridge.stop(self._host_id(server_id))
|
||||
@@ -456,6 +480,9 @@ class McpServerRegistry:
|
||||
)
|
||||
headers[key] = value
|
||||
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)
|
||||
try:
|
||||
return self.bridge.start(
|
||||
@@ -463,7 +490,9 @@ class McpServerRegistry:
|
||||
self._backend(record),
|
||||
self._server_dir(server_id),
|
||||
list(record.get("permissions", [])),
|
||||
lambda _host, message: self._unavailable(server_id, message),
|
||||
lambda _host, message: self._unavailable(
|
||||
server_id, generation, message
|
||||
),
|
||||
command_override=(
|
||||
[record["command"], *record.get("args", [])]
|
||||
if record.get("command")
|
||||
@@ -476,6 +505,8 @@ class McpServerRegistry:
|
||||
headers=headers,
|
||||
)
|
||||
except McpBridgeError as exc:
|
||||
self._generations.pop(server_id, None)
|
||||
self.bridge.remove(host_id)
|
||||
raise McpRegistryError(
|
||||
exc.code, exc.message, status_code=exc.status_code
|
||||
) from exc
|
||||
@@ -496,10 +527,13 @@ class McpServerRegistry:
|
||||
|
||||
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
|
||||
# sure tools registered immediately before the callback are also removed.
|
||||
with self._lifecycle_lock:
|
||||
if self._generations.get(server_id) is not generation:
|
||||
return
|
||||
self._generations.pop(server_id, None)
|
||||
try:
|
||||
with self._lock:
|
||||
record = self._records.get(server_id)
|
||||
@@ -694,7 +728,7 @@ class McpServerRegistry:
|
||||
|
||||
@staticmethod
|
||||
def _backend(record: dict[str, Any]) -> PluginBackend:
|
||||
return PluginBackend(
|
||||
return _McpConnectionBackend(
|
||||
type="mcp",
|
||||
transport="stdio",
|
||||
command=record.get("command") or "http",
|
||||
@@ -755,9 +789,89 @@ class McpServerRegistry:
|
||||
|
||||
@staticmethod
|
||||
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}"
|
||||
|
||||
@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(
|
||||
self, server_id: str, key: str, kind: str = "environment"
|
||||
) -> bool:
|
||||
@@ -857,7 +971,9 @@ class McpServerRegistry:
|
||||
config_fields = set(McpServerConfig.model_fields)
|
||||
try:
|
||||
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")
|
||||
record = _McpServerRecord.model_validate(raw)
|
||||
config = record.model_dump(mode="json", include=config_fields)
|
||||
|
||||
@@ -5,13 +5,12 @@ import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
|
||||
_MCP_CREDENTIAL_PREFIX = "mcp."
|
||||
@@ -29,7 +28,9 @@ def validate_provider_credential_id(credential_id: str | None) -> None:
|
||||
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
||||
|
||||
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):
|
||||
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:
|
||||
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
|
||||
|
||||
_development_aliases = {
|
||||
_development_aliases: ClassVar[dict[str, str]] = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
}
|
||||
@@ -85,7 +86,9 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
return Fernet(environment_key.encode("ascii"))
|
||||
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)
|
||||
self._restrict(key_path.parent, 0o700)
|
||||
@@ -102,7 +105,9 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
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]:
|
||||
_, store_path = self._paths()
|
||||
@@ -111,11 +116,16 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
data = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
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(
|
||||
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
|
||||
|
||||
def _write_tokens(self, tokens: dict[str, str]) -> None:
|
||||
@@ -196,6 +206,22 @@ class EncryptedCredentialStore:
|
||||
self._write_tokens(tokens)
|
||||
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:
|
||||
def __init__(self, *resolvers: CredentialResolver) -> None:
|
||||
|
||||
+6
-13
@@ -100,15 +100,8 @@ from app.services import (
|
||||
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):
|
||||
"""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:
|
||||
return await asyncio.to_thread(operation)
|
||||
except McpRegistryError as exc:
|
||||
@@ -521,19 +514,19 @@ async def uninstall_skill(skill_id: str) -> OperationResponse:
|
||||
# Independent MCP Server Registry
|
||||
@router.get("/mcp/servers", response_model=McpServerListResponse, tags=["MCP Servers"])
|
||||
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(
|
||||
"/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"]
|
||||
)
|
||||
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"])
|
||||
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(
|
||||
@@ -543,7 +536,7 @@ async def get_mcp_server(server_id: str) -> McpServer:
|
||||
)
|
||||
async def list_mcp_server_tools(server_id: str) -> 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"]
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
+180
-6
@@ -2,6 +2,8 @@ import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts import (
|
||||
McpServerSecretStatus,
|
||||
McpServerSecretWriteRequest,
|
||||
@@ -61,9 +63,7 @@ def test_mcp_secret_routes_offload_blocking_lifecycle_work(monkeypatch) -> None:
|
||||
)
|
||||
)
|
||||
deleted = asyncio.run(
|
||||
routes.delete_mcp_server_secret(
|
||||
"server-1", "TOKEN", kind="environment"
|
||||
)
|
||||
routes.delete_mcp_server_secret("server-1", "TOKEN", kind="environment")
|
||||
)
|
||||
|
||||
assert written.configured is True
|
||||
@@ -77,6 +77,172 @@ def test_health() -> None:
|
||||
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:
|
||||
response = asyncio.run(service_status())
|
||||
|
||||
@@ -93,7 +259,9 @@ def test_core_collections_are_typed() -> None:
|
||||
|
||||
assert notes.items == []
|
||||
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 [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"]
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
@@ -111,7 +112,9 @@ def test_update_disables_server_and_revokes_command_trust() -> None:
|
||||
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()
|
||||
created = service.create(request())
|
||||
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] = []
|
||||
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)
|
||||
assert removed == [f"mcp.{created.server_id}"]
|
||||
@@ -175,6 +180,180 @@ def test_unavailable_server_removes_bridge_host(monkeypatch) -> None:
|
||||
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:
|
||||
service = registry(launch=False)
|
||||
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"
|
||||
|
||||
|
||||
@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:
|
||||
service = registry()
|
||||
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)
|
||||
|
||||
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)
|
||||
names = [
|
||||
item.name
|
||||
for item in service.tools.definitions()
|
||||
if item.source == "mcp_server"
|
||||
item.name for item in service.tools.definitions() if item.source == "mcp_server"
|
||||
]
|
||||
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:
|
||||
list(pool.map(lambda item: service.disable(item.server_id), servers * 2))
|
||||
|
||||
@@ -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、环境变量或权限后,旧授权和测试结果立即失效。
|
||||
|
||||
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 但配置已提交、版本无法重试或密文失去清理入口的状态。
|
||||
|
||||
@@ -34,12 +71,16 @@ Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据
|
||||
3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 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 被转发到其他站点。
|
||||
|
||||
启动与 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 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。
|
||||
|
||||
`uvx` 模板使用 `--isolated`、明确的 `--from` 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 `403 MCP_SANDBOX_REQUIRED`;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。
|
||||
@@ -75,10 +116,26 @@ npm test
|
||||
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. 后续边界
|
||||
|
||||
### 本轮 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 只能证明宿主协议行为,不能代表所有实现兼容;
|
||||
- C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract;
|
||||
- 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。
|
||||
|
||||
@@ -92,4 +92,78 @@ describe('McpServersView', () => {
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
|
||||
import * as service from '@/services/mcpServerService'
|
||||
|
||||
type SecretKind = 'environment' | 'header'
|
||||
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
|
||||
|
||||
const servers = ref<McpServer[]>([])
|
||||
const busy = ref('')
|
||||
@@ -22,18 +21,11 @@ const secretHeaderKeysText = ref('')
|
||||
const permissionsText = ref('')
|
||||
const rawConfig = ref('')
|
||||
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 服务器')
|
||||
|
||||
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() {
|
||||
error.value = ''
|
||||
try { servers.value = await service.listMcpServers() }
|
||||
@@ -41,7 +33,7 @@ async function load() {
|
||||
}
|
||||
|
||||
function resetEditor(input: McpServerInput) {
|
||||
Object.assign(form, input)
|
||||
Object.assign(form, emptyMcpConfig(), { version: undefined }, input)
|
||||
argsText.value = input.args.join('\n')
|
||||
environmentText.value = JSON.stringify(input.environment, null, 2)
|
||||
headersText.value = JSON.stringify(input.headers, null, 2)
|
||||
@@ -53,13 +45,19 @@ function resetEditor(input: McpServerInput) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
importedSecrets.value = []
|
||||
editingId.value = null
|
||||
editingOriginal.value = null
|
||||
resetEditor(emptyForm())
|
||||
resetEditor(emptyMcpConfig())
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(server: McpServer) {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
importedSecrets.value = []
|
||||
editingId.value = server.server_id
|
||||
editingOriginal.value = server
|
||||
resetEditor({
|
||||
@@ -109,38 +107,69 @@ function formPayload(): McpServerInput {
|
||||
}
|
||||
}
|
||||
|
||||
function payload(): McpServerInput {
|
||||
if (editorMode.value === 'form') return formPayload()
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse(rawConfig.value) } catch { throw new Error('服务器配置不是有效 JSON') }
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('服务器配置必须是 JSON 对象')
|
||||
const value = parsed as McpServerInput
|
||||
if (editingId.value) value.version = form.version
|
||||
return value
|
||||
function payload(requireConnection = true): McpServerInput {
|
||||
const { config, secrets } = editorMode.value === 'form'
|
||||
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
|
||||
// Keep only still-declared drafts. A mode switch must not discard imported keys,
|
||||
// and editing the declaration must not later send a removed key to the Secret API.
|
||||
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
|
||||
if (editingId.value) config.version = form.version
|
||||
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') {
|
||||
try {
|
||||
if (mode === editorMode.value) return
|
||||
if (mode === 'json') rawConfig.value = JSON.stringify(formPayload(), null, 2)
|
||||
else resetEditor(payload())
|
||||
error.value = ''
|
||||
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
|
||||
else resetEditor(payload(false))
|
||||
editorMode.value = mode
|
||||
} catch (cause) { error.value = message(cause, '配置转换失败') }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
let saved: McpServer | undefined
|
||||
try {
|
||||
error.value = ''
|
||||
const input = payload()
|
||||
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
|
||||
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
|
||||
busy.value = 'save'
|
||||
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
dialogOpen.value = false
|
||||
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
// 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()
|
||||
} catch (cause) { error.value = message(cause, '保存失败') }
|
||||
} catch (cause) {
|
||||
if (saved) await load()
|
||||
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
|
||||
}
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
importedSecrets.value = []
|
||||
rawConfig.value = ''
|
||||
environmentText.value = '{}'
|
||||
headersText.value = '{}'
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
function executionChanged(server: McpServer, input: McpServerInput) {
|
||||
const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||
const current = [
|
||||
@@ -212,9 +241,12 @@ onMounted(load)
|
||||
</article>
|
||||
</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">
|
||||
<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>
|
||||
<template v-if="editorMode === 'form'">
|
||||
<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>
|
||||
<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>
|
||||
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>Secret 只填写键名,明文请在保存后的服务器卡片中单独录入。</small></label>
|
||||
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存。其他敏感值请显式声明;不要把密钥放入命令或参数。</small><small>兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。</small></label>
|
||||
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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); }
|
||||
.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; }
|
||||
|
||||
@@ -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') }
|
||||
})
|
||||
})
|
||||
@@ -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('服务器名称必须为 1–80 个字符')
|
||||
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('密钥值必须为 1–32768 个字符')
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user