feat(mcp): 完善独立 MCP 服务器配置中心与连接生命周期管理 #13
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
|
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
|
||||||
|
|
||||||
> 当前基线:2026-09-02。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host,以及 Plugin Command/Settings 后端 Contract 和前端 Service。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
|
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1/P0。Streamable HTTP MCP、真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.agent.builtin_tools import register_builtin_tools
|
|||||||
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||||
from app.config import BACKEND_DIR, get_settings
|
from app.config import BACKEND_DIR, get_settings
|
||||||
from app.extensions import PluginRuntime, SkillRuntime
|
from app.extensions import PluginRuntime, SkillRuntime
|
||||||
|
from app.extensions.mcp_registry import McpServerRegistry
|
||||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||||
from app.providers.credentials import (
|
from app.providers.credentials import (
|
||||||
ChainedCredentialResolver,
|
ChainedCredentialResolver,
|
||||||
@@ -22,6 +23,7 @@ class ApplicationContainer:
|
|||||||
permissions: PermissionManager
|
permissions: PermissionManager
|
||||||
skills: SkillRuntime
|
skills: SkillRuntime
|
||||||
plugins: PluginRuntime
|
plugins: PluginRuntime
|
||||||
|
mcp_servers: McpServerRegistry
|
||||||
agent: AgentRuntime
|
agent: AgentRuntime
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +63,14 @@ def build_container() -> ApplicationContainer:
|
|||||||
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
|
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
|
||||||
plugins.enable("text-tools")
|
plugins.enable("text-tools")
|
||||||
|
|
||||||
|
mcp_servers = McpServerRegistry(
|
||||||
|
tools,
|
||||||
|
credentials,
|
||||||
|
settings.data_dir,
|
||||||
|
allow_process_launch=settings.environment == "development",
|
||||||
|
)
|
||||||
|
mcp_servers.restore_enabled()
|
||||||
|
|
||||||
skills = SkillRuntime(tools)
|
skills = SkillRuntime(tools)
|
||||||
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
|
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
|
||||||
skills.enable("knowledge-assistant")
|
skills.enable("knowledge-assistant")
|
||||||
@@ -81,6 +91,7 @@ def build_container() -> ApplicationContainer:
|
|||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
plugins=plugins,
|
plugins=plugins,
|
||||||
|
mcp_servers=mcp_servers,
|
||||||
agent=agent,
|
agent=agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class ToolDefinition(Contract):
|
|||||||
description: str
|
description: str
|
||||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||||
permission: str | None = None
|
permission: str | None = None
|
||||||
source: Literal["builtin", "plugin"] = "builtin"
|
source: Literal["builtin", "plugin", "mcp_server"] = "builtin"
|
||||||
|
|
||||||
|
|
||||||
class ToolCall(Contract):
|
class ToolCall(Contract):
|
||||||
@@ -486,6 +486,72 @@ class PluginHostStatus(Contract):
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Independent user-managed MCP Server Registry. This is deliberately separate
|
||||||
|
# from Plugin manifests: a server can contribute tools without being a Plugin.
|
||||||
|
class McpServerTransport(str, Enum):
|
||||||
|
stdio = "stdio"
|
||||||
|
streamable_http = "streamable_http"
|
||||||
|
sse = "sse"
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerCreateRequest(Contract):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
transport: McpServerTransport = McpServerTransport.stdio
|
||||||
|
command: str = Field(min_length=1, max_length=1024)
|
||||||
|
args: list[str] = Field(default_factory=list, max_length=64)
|
||||||
|
environment: dict[str, str] = Field(default_factory=dict)
|
||||||
|
secret_environment_keys: list[str] = Field(default_factory=list)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
|
||||||
|
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerUpdateRequest(McpServerCreateRequest):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerSecretWriteRequest(Contract):
|
||||||
|
secret: SecretStr = Field(min_length=1, max_length=32768)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerSecretStatus(Contract):
|
||||||
|
key: str
|
||||||
|
configured: bool
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerTrustRequest(Contract):
|
||||||
|
command_digest: str = Field(min_length=64, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServer(Contract):
|
||||||
|
server_id: str
|
||||||
|
name: str
|
||||||
|
transport: McpServerTransport
|
||||||
|
command: str
|
||||||
|
args: list[str] = Field(default_factory=list)
|
||||||
|
environment: dict[str, str] = Field(default_factory=dict)
|
||||||
|
secret_environment: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
startup_timeout_seconds: float
|
||||||
|
tool_timeout_seconds: float
|
||||||
|
enabled: bool = False
|
||||||
|
trusted: bool = False
|
||||||
|
command_digest: str
|
||||||
|
command_summary: str
|
||||||
|
status: PluginHostState = PluginHostState.stopped
|
||||||
|
tools_count: int = 0
|
||||||
|
protocol_version: str | None = None
|
||||||
|
remote_server_name: str | None = None
|
||||||
|
remote_server_version: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
last_tested_at: datetime | None = None
|
||||||
|
last_test_succeeded: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerListResponse(Contract):
|
||||||
|
items: list[McpServer] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PluginCommandLocation(str, Enum):
|
class PluginCommandLocation(str, Enum):
|
||||||
command_palette = "command_palette"
|
command_palette = "command_palette"
|
||||||
context_menu = "context_menu"
|
context_menu = "context_menu"
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ class McpStdioClient:
|
|||||||
command: list[str],
|
command: list[str],
|
||||||
*,
|
*,
|
||||||
cwd: Path,
|
cwd: Path,
|
||||||
|
environment: dict[str, str] | None = None,
|
||||||
on_seen: Callable[[], None],
|
on_seen: Callable[[], None],
|
||||||
on_broken: Callable[[str], None],
|
on_broken: Callable[[str], None],
|
||||||
on_tools_changed: Callable[[], None],
|
on_tools_changed: Callable[[], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
|
self.environment = environment or {}
|
||||||
self.on_seen = on_seen
|
self.on_seen = on_seen
|
||||||
self.on_broken = on_broken
|
self.on_broken = on_broken
|
||||||
self.on_tools_changed = on_tools_changed
|
self.on_tools_changed = on_tools_changed
|
||||||
@@ -99,6 +101,7 @@ class McpStdioClient:
|
|||||||
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
|
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
|
||||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
||||||
environment = _subprocess_environment()
|
environment = _subprocess_environment()
|
||||||
|
environment.update(self.environment)
|
||||||
environment.setdefault("PYTHONUNBUFFERED", "1")
|
environment.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
try:
|
try:
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
@@ -383,6 +386,10 @@ class McpBridge:
|
|||||||
package_path: Path,
|
package_path: Path,
|
||||||
declared_permissions: list[str],
|
declared_permissions: list[str],
|
||||||
on_unavailable: Callable[[str, str], None],
|
on_unavailable: Callable[[str, str], None],
|
||||||
|
*,
|
||||||
|
command_override: list[str] | None = None,
|
||||||
|
environment: dict[str, str] | None = None,
|
||||||
|
tool_source: str = "plugin",
|
||||||
) -> list[McpDiscoveredTool]:
|
) -> list[McpDiscoveredTool]:
|
||||||
if backend.transport != "stdio":
|
if backend.transport != "stdio":
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
@@ -390,7 +397,7 @@ class McpBridge:
|
|||||||
"Phase C only supports the MCP stdio transport.",
|
"Phase C only supports the MCP stdio transport.",
|
||||||
status_code=501,
|
status_code=501,
|
||||||
)
|
)
|
||||||
command = self._resolve_command(package_path, backend)
|
command = command_override or self._resolve_command(package_path, backend)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
status = PluginHostStatus(
|
status = PluginHostStatus(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
@@ -420,6 +427,7 @@ class McpBridge:
|
|||||||
client = McpStdioClient(
|
client = McpStdioClient(
|
||||||
command,
|
command,
|
||||||
cwd=package_path,
|
cwd=package_path,
|
||||||
|
environment=environment,
|
||||||
on_seen=seen,
|
on_seen=seen,
|
||||||
on_broken=broken,
|
on_broken=broken,
|
||||||
on_tools_changed=tools_changed,
|
on_tools_changed=tools_changed,
|
||||||
@@ -470,7 +478,7 @@ class McpBridge:
|
|||||||
status.server_version = _optional_string(server_info.get("version"))
|
status.server_version = _optional_string(server_info.get("version"))
|
||||||
client.notify("notifications/initialized")
|
client.notify("notifications/initialized")
|
||||||
discovered = self._discover_tools(
|
discovered = self._discover_tools(
|
||||||
plugin_id, client, backend, declared_permissions
|
plugin_id, client, backend, declared_permissions, tool_source
|
||||||
)
|
)
|
||||||
status.status = PluginHostState.ready
|
status.status = PluginHostState.ready
|
||||||
status.tools_count = len(discovered)
|
status.tools_count = len(discovered)
|
||||||
@@ -601,6 +609,7 @@ class McpBridge:
|
|||||||
client: McpStdioClient,
|
client: McpStdioClient,
|
||||||
backend: PluginBackend,
|
backend: PluginBackend,
|
||||||
declared_permissions: list[str],
|
declared_permissions: list[str],
|
||||||
|
tool_source: str,
|
||||||
) -> list[McpDiscoveredTool]:
|
) -> list[McpDiscoveredTool]:
|
||||||
discovered: list[McpDiscoveredTool] = []
|
discovered: list[McpDiscoveredTool] = []
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
@@ -620,7 +629,7 @@ class McpBridge:
|
|||||||
)
|
)
|
||||||
for raw in raw_tools:
|
for raw in raw_tools:
|
||||||
discovered.append(
|
discovered.append(
|
||||||
self._map_tool(plugin_id, raw, declared_permissions)
|
self._map_tool(plugin_id, raw, declared_permissions, tool_source)
|
||||||
)
|
)
|
||||||
if len(discovered) > MAX_MCP_TOOLS:
|
if len(discovered) > MAX_MCP_TOOLS:
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
@@ -648,7 +657,10 @@ class McpBridge:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _map_tool(
|
def _map_tool(
|
||||||
plugin_id: str, raw: Any, declared_permissions: list[str]
|
plugin_id: str,
|
||||||
|
raw: Any,
|
||||||
|
declared_permissions: list[str],
|
||||||
|
tool_source: str = "plugin",
|
||||||
) -> McpDiscoveredTool:
|
) -> McpDiscoveredTool:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
@@ -712,7 +724,7 @@ class McpBridge:
|
|||||||
description=description if isinstance(description, str) else remote_name,
|
description=description if isinstance(description, str) else remote_name,
|
||||||
parameters=schema,
|
parameters=schema,
|
||||||
permission=permission,
|
permission=permission,
|
||||||
source="plugin",
|
source=tool_source,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
"""Independent, user-managed MCP server registry for development builds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, create_model
|
||||||
|
|
||||||
|
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||||
|
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||||
|
from app.contracts import (
|
||||||
|
McpServer,
|
||||||
|
McpServerCreateRequest,
|
||||||
|
McpServerSecretStatus,
|
||||||
|
McpServerTransport,
|
||||||
|
McpServerUpdateRequest,
|
||||||
|
PluginBackend,
|
||||||
|
PluginHostState,
|
||||||
|
)
|
||||||
|
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
|
||||||
|
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
||||||
|
|
||||||
|
_ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
class McpRegistryError(RuntimeError):
|
||||||
|
def __init__(self, code: str, message: str, *, status_code: int = 422) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerRegistry:
|
||||||
|
"""Persists configuration and owns stdio host/tool lifecycles."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
credentials: EncryptedCredentialStore,
|
||||||
|
data_dir: Path,
|
||||||
|
*,
|
||||||
|
allow_process_launch: bool,
|
||||||
|
bridge: McpBridge | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.tools = registry
|
||||||
|
self.credentials = credentials
|
||||||
|
self.data_dir = data_dir
|
||||||
|
self.allow_process_launch = allow_process_launch
|
||||||
|
self.bridge = bridge or McpBridge()
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._records = self._read()
|
||||||
|
self._registered: dict[str, list[str]] = {}
|
||||||
|
self._last_status: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def list(self) -> list[McpServer]:
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
self._public(server_id, record)
|
||||||
|
for server_id, record in self._records.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, server_id: str) -> McpServer:
|
||||||
|
with self._lock:
|
||||||
|
return self._public(server_id, self._record(server_id))
|
||||||
|
|
||||||
|
def create(self, request: McpServerCreateRequest) -> McpServer:
|
||||||
|
self._validate(request)
|
||||||
|
server_id = uuid4().hex[:12]
|
||||||
|
record = request.model_dump(mode="json")
|
||||||
|
record["name"] = request.name.strip()
|
||||||
|
record["command"] = request.command.strip()
|
||||||
|
record.update(enabled=False, approved_digest=None)
|
||||||
|
with self._lock:
|
||||||
|
updated = {**self._records, server_id: record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def update(self, server_id: str, request: McpServerUpdateRequest) -> McpServer:
|
||||||
|
self._validate(request)
|
||||||
|
self.disable(server_id)
|
||||||
|
with self._lock:
|
||||||
|
previous = self._record(server_id)
|
||||||
|
removed = set(previous.get("secret_environment_keys", [])) - set(
|
||||||
|
request.secret_environment_keys
|
||||||
|
)
|
||||||
|
record = request.model_dump(mode="json")
|
||||||
|
record["name"] = request.name.strip()
|
||||||
|
record["command"] = request.command.strip()
|
||||||
|
record.update(enabled=False, approved_digest=None)
|
||||||
|
updated = {**self._records, server_id: record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
self._last_status.pop(server_id, None)
|
||||||
|
for key in removed:
|
||||||
|
try:
|
||||||
|
self.credentials.delete(self._secret_id(server_id, key))
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def delete(self, server_id: str) -> None:
|
||||||
|
self.disable(server_id)
|
||||||
|
with self._lock:
|
||||||
|
record = self._record(server_id)
|
||||||
|
secret_ids = [
|
||||||
|
self._secret_id(server_id, key)
|
||||||
|
for key in record.get("secret_environment_keys", [])
|
||||||
|
]
|
||||||
|
updated = dict(self._records)
|
||||||
|
del updated[server_id]
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
self._last_status.pop(server_id, None)
|
||||||
|
try:
|
||||||
|
self.credentials.delete_many(secret_ids)
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
self.bridge.remove(self._host_id(server_id))
|
||||||
|
|
||||||
|
def trust(self, server_id: str, command_digest: str) -> McpServer:
|
||||||
|
with self._lock:
|
||||||
|
record = self._record(server_id)
|
||||||
|
current = self._digest(record)
|
||||||
|
if command_digest != current:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_TRUST_DIGEST_STALE",
|
||||||
|
"MCP server configuration changed; review it again.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
approved = {**record, "approved_digest": current}
|
||||||
|
updated = {**self._records, server_id: approved}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def put_secret(
|
||||||
|
self, server_id: str, key: str, secret: str
|
||||||
|
) -> McpServerSecretStatus:
|
||||||
|
with self._lock:
|
||||||
|
record = self._record(server_id)
|
||||||
|
self._validate_environment_key(key)
|
||||||
|
if key not in record.get("secret_environment_keys", []):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_NOT_DECLARED",
|
||||||
|
"Secret environment key is not declared in this server configuration.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.credentials.put(self._secret_id(server_id, key), secret)
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
return McpServerSecretStatus(key=key, configured=True)
|
||||||
|
|
||||||
|
def delete_secret(self, server_id: str, key: str) -> McpServerSecretStatus:
|
||||||
|
record = self._record(server_id)
|
||||||
|
if key not in record.get("secret_environment_keys", []):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_NOT_DECLARED",
|
||||||
|
"Secret environment key is not declared in this server configuration.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.credentials.delete(self._secret_id(server_id, key))
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
return McpServerSecretStatus(key=key, configured=False)
|
||||||
|
|
||||||
|
def test(self, server_id: str) -> McpServer:
|
||||||
|
record = self._record(server_id)
|
||||||
|
if record.get("enabled"):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SERVER_ALREADY_ENABLED",
|
||||||
|
"Disable the MCP server before running an isolated connection test.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
self._require_launch_allowed(record)
|
||||||
|
try:
|
||||||
|
discovered = self._start(server_id, record)
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_status[server_id] = {
|
||||||
|
"status": PluginHostState.error,
|
||||||
|
"error": str(exc),
|
||||||
|
"last_tested_at": datetime.now(UTC),
|
||||||
|
"last_test_succeeded": False,
|
||||||
|
}
|
||||||
|
raise
|
||||||
|
status = self.bridge.status(self._host_id(server_id), self._backend(record))
|
||||||
|
self._last_status[server_id] = {
|
||||||
|
"status": PluginHostState.stopped,
|
||||||
|
"tools_count": len(discovered),
|
||||||
|
"protocol_version": status.protocol_version,
|
||||||
|
"remote_server_name": status.server_name,
|
||||||
|
"remote_server_version": status.server_version,
|
||||||
|
"error": None,
|
||||||
|
"last_tested_at": datetime.now(UTC),
|
||||||
|
"last_test_succeeded": True,
|
||||||
|
}
|
||||||
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def enable(self, server_id: str) -> McpServer:
|
||||||
|
record = self._record(server_id)
|
||||||
|
if server_id in self._registered:
|
||||||
|
return self.get(server_id)
|
||||||
|
self._require_launch_allowed(record)
|
||||||
|
discovered = self._start(server_id, record)
|
||||||
|
registered: list[str] = []
|
||||||
|
try:
|
||||||
|
for item in discovered:
|
||||||
|
self._register(server_id, item)
|
||||||
|
registered.append(item.definition.name)
|
||||||
|
except Exception:
|
||||||
|
for name in registered:
|
||||||
|
self.tools.unregister(name)
|
||||||
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
enabled_record = {**record, "enabled": True}
|
||||||
|
updated = {**self._records, server_id: enabled_record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
self._registered[server_id] = registered
|
||||||
|
except McpRegistryError:
|
||||||
|
for name in registered:
|
||||||
|
self.tools.unregister(name)
|
||||||
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
raise
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def disable(self, server_id: str) -> McpServer:
|
||||||
|
with self._lock:
|
||||||
|
record = self._record(server_id)
|
||||||
|
disabled_record = {**record, "enabled": False}
|
||||||
|
updated = {**self._records, server_id: disabled_record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
|
for name in self._registered.pop(server_id, []):
|
||||||
|
self.tools.unregister(name)
|
||||||
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
return self.get(server_id)
|
||||||
|
|
||||||
|
def restore_enabled(self) -> None:
|
||||||
|
if not self._records:
|
||||||
|
return
|
||||||
|
for server_id, record in list(self._records.items()):
|
||||||
|
if record.get("enabled"):
|
||||||
|
try:
|
||||||
|
self.enable(server_id)
|
||||||
|
except (McpRegistryError, ValueError, OSError) as exc:
|
||||||
|
self._records[server_id] = {**record, "enabled": False}
|
||||||
|
self._last_status[server_id] = {
|
||||||
|
"status": PluginHostState.error,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
self._write()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
for server_id in list(self._records):
|
||||||
|
for name in self._registered.pop(server_id, []):
|
||||||
|
self.tools.unregister(name)
|
||||||
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
|
||||||
|
def _start(self, server_id: str, record: dict[str, Any]) -> list[McpDiscoveredTool]:
|
||||||
|
environment = dict(record.get("environment", {}))
|
||||||
|
for key in record.get("secret_environment_keys", []):
|
||||||
|
try:
|
||||||
|
value = self.credentials.resolve(self._secret_id(server_id, key))
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
if value is None:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_REQUIRED",
|
||||||
|
f"Secret environment variable is not configured: {key}",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
environment[key] = value
|
||||||
|
host_id = self._host_id(server_id)
|
||||||
|
self.bridge.remove(host_id)
|
||||||
|
try:
|
||||||
|
return self.bridge.start(
|
||||||
|
host_id,
|
||||||
|
self._backend(record),
|
||||||
|
self._server_dir(server_id),
|
||||||
|
list(record.get("permissions", [])),
|
||||||
|
lambda _host, message: self._unavailable(server_id, message),
|
||||||
|
command_override=[record["command"], *record.get("args", [])],
|
||||||
|
environment=environment,
|
||||||
|
tool_source="mcp_server",
|
||||||
|
)
|
||||||
|
except McpBridgeError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
exc.code, exc.message, status_code=exc.status_code
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _register(self, server_id: str, discovered: McpDiscoveredTool) -> None:
|
||||||
|
definition = discovered.definition
|
||||||
|
model_name = "McpArgs_" + re.sub(r"\W+", "_", definition.name)
|
||||||
|
arguments_model = create_model(model_name, __config__=ConfigDict(extra="allow"))
|
||||||
|
|
||||||
|
async def executor(arguments: BaseModel, context: ToolExecutionContext) -> Any:
|
||||||
|
return await self.bridge.call_tool(
|
||||||
|
self._host_id(server_id),
|
||||||
|
discovered.remote_name,
|
||||||
|
arguments.model_dump(exclude_unset=True),
|
||||||
|
request_id=context.tool_call_id
|
||||||
|
or f"{context.run_id}:{definition.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tools.register(definition, arguments_model, executor)
|
||||||
|
|
||||||
|
def _unavailable(self, server_id: str, message: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
for name in self._registered.pop(server_id, []):
|
||||||
|
self.tools.unregister(name)
|
||||||
|
record = self._records.get(server_id)
|
||||||
|
if record is not None:
|
||||||
|
self._records[server_id] = {**record, "enabled": False}
|
||||||
|
self._last_status[server_id] = {
|
||||||
|
"status": PluginHostState.unhealthy,
|
||||||
|
"error": message,
|
||||||
|
}
|
||||||
|
self._write()
|
||||||
|
|
||||||
|
def _require_launch_allowed(self, record: dict[str, Any]) -> None:
|
||||||
|
if record.get("transport") != McpServerTransport.stdio.value:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_TRANSPORT_UNSUPPORTED",
|
||||||
|
"C.1 currently supports stdio; Streamable HTTP and SSE are reserved for a later increment.",
|
||||||
|
status_code=501,
|
||||||
|
)
|
||||||
|
if not self.allow_process_launch:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SANDBOX_REQUIRED",
|
||||||
|
"Python process launch is disabled outside development until the desktop sandbox is available.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
if record.get("approved_digest") != self._digest(record):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_TRUST_APPROVAL_REQUIRED",
|
||||||
|
"Review and approve the current MCP command before testing or enabling it.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _public(self, server_id: str, record: dict[str, Any]) -> McpServer:
|
||||||
|
digest = self._digest(record)
|
||||||
|
backend = self._backend(record)
|
||||||
|
status = self.bridge.status(self._host_id(server_id), backend)
|
||||||
|
cached = self._last_status.get(server_id, {})
|
||||||
|
return McpServer(
|
||||||
|
server_id=server_id,
|
||||||
|
name=record["name"],
|
||||||
|
transport=record["transport"],
|
||||||
|
command=record["command"],
|
||||||
|
args=list(record.get("args", [])),
|
||||||
|
environment=dict(record.get("environment", {})),
|
||||||
|
secret_environment={
|
||||||
|
key: self._secret_configured(server_id, key)
|
||||||
|
for key in record.get("secret_environment_keys", [])
|
||||||
|
},
|
||||||
|
permissions=list(record.get("permissions", [])),
|
||||||
|
startup_timeout_seconds=backend.startup_timeout_seconds,
|
||||||
|
tool_timeout_seconds=backend.tool_timeout_seconds,
|
||||||
|
enabled=bool(record.get("enabled")),
|
||||||
|
trusted=record.get("approved_digest") == digest,
|
||||||
|
command_digest=digest,
|
||||||
|
command_summary=self._summary(record),
|
||||||
|
status=status.status
|
||||||
|
if record.get("enabled")
|
||||||
|
else cached.get("status", PluginHostState.stopped),
|
||||||
|
tools_count=status.tools_count
|
||||||
|
if record.get("enabled")
|
||||||
|
else cached.get("tools_count", 0),
|
||||||
|
protocol_version=status.protocol_version
|
||||||
|
if record.get("enabled")
|
||||||
|
else cached.get("protocol_version"),
|
||||||
|
remote_server_name=status.server_name
|
||||||
|
if record.get("enabled")
|
||||||
|
else cached.get("remote_server_name"),
|
||||||
|
remote_server_version=status.server_version
|
||||||
|
if record.get("enabled")
|
||||||
|
else cached.get("remote_server_version"),
|
||||||
|
error=status.error if record.get("enabled") else cached.get("error"),
|
||||||
|
last_tested_at=cached.get("last_tested_at"),
|
||||||
|
last_test_succeeded=cached.get("last_test_succeeded"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate(self, request: McpServerCreateRequest) -> None:
|
||||||
|
if not request.name.strip():
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SERVER_NAME_INVALID", "MCP server name cannot be blank."
|
||||||
|
)
|
||||||
|
if not request.command.strip() or "\x00" in request.command:
|
||||||
|
raise McpRegistryError("MCP_COMMAND_INVALID", "MCP executable is invalid.")
|
||||||
|
if any("\x00" in arg for arg in request.args):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_COMMAND_INVALID", "MCP argument contains a null byte."
|
||||||
|
)
|
||||||
|
for key in [*request.environment, *request.secret_environment_keys]:
|
||||||
|
self._validate_environment_key(key)
|
||||||
|
if set(request.environment) & set(request.secret_environment_keys):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_ENVIRONMENT_INVALID",
|
||||||
|
"An environment key cannot be both plain and secret.",
|
||||||
|
)
|
||||||
|
unknown_permissions = set(request.permissions) - KNOWN_PERMISSIONS
|
||||||
|
if unknown_permissions:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_PERMISSION_INVALID",
|
||||||
|
f"Unknown MCP permission: {min(unknown_permissions)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_environment_key(key: str) -> None:
|
||||||
|
if not _ENVIRONMENT_KEY.fullmatch(key):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_ENVIRONMENT_INVALID", f"Invalid environment variable name: {key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backend(record: dict[str, Any]) -> PluginBackend:
|
||||||
|
return PluginBackend(
|
||||||
|
type="mcp",
|
||||||
|
transport="stdio",
|
||||||
|
command=record["command"],
|
||||||
|
args=record.get("args", []),
|
||||||
|
startup_timeout_seconds=record.get("startup_timeout_seconds", 15),
|
||||||
|
tool_timeout_seconds=record.get("tool_timeout_seconds", 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _host_id(server_id: str) -> str:
|
||||||
|
return f"mcp.{server_id}"
|
||||||
|
|
||||||
|
def _server_dir(self, server_id: str) -> Path:
|
||||||
|
path = self.data_dir / "mcp" / "workdirs" / server_id
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(record: dict[str, Any]) -> str:
|
||||||
|
executable = {
|
||||||
|
key: record.get(key)
|
||||||
|
for key in (
|
||||||
|
"transport",
|
||||||
|
"command",
|
||||||
|
"args",
|
||||||
|
"environment",
|
||||||
|
"secret_environment_keys",
|
||||||
|
"permissions",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
executable, sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summary(record: dict[str, Any]) -> str:
|
||||||
|
return " ".join(
|
||||||
|
[
|
||||||
|
record["command"],
|
||||||
|
*[
|
||||||
|
json.dumps(arg, ensure_ascii=False)
|
||||||
|
for arg in record.get("args", [])
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _secret_id(server_id: str, key: str) -> str:
|
||||||
|
suffix = hashlib.sha256(key.encode()).hexdigest()[:20]
|
||||||
|
return f"mcp.{server_id}.{suffix}"
|
||||||
|
|
||||||
|
def _secret_configured(self, server_id: str, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
return self.credentials.has(self._secret_id(server_id, key))
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _record(self, server_id: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return self._records[server_id]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SERVER_NOT_FOUND",
|
||||||
|
"MCP server configuration was not found.",
|
||||||
|
status_code=404,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _path(self) -> Path:
|
||||||
|
return self.data_dir / "mcp" / "servers.json"
|
||||||
|
|
||||||
|
def _read(self) -> dict[str, dict[str, Any]]:
|
||||||
|
if not self._path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
value = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_REGISTRY_INVALID",
|
||||||
|
"MCP server registry cannot be loaded.",
|
||||||
|
status_code=500,
|
||||||
|
) from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_REGISTRY_INVALID",
|
||||||
|
"MCP server registry has an invalid format.",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _write(self, records: dict[str, dict[str, Any]] | None = None) -> None:
|
||||||
|
temporary = self._path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(
|
||||||
|
records if records is not None else self._records,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(self._path)
|
||||||
|
except OSError as exc:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_REGISTRY_WRITE_FAILED",
|
||||||
|
"MCP server registry cannot be written.",
|
||||||
|
status_code=500,
|
||||||
|
) from exc
|
||||||
@@ -19,6 +19,7 @@ async def lifespan(_: FastAPI):
|
|||||||
yield
|
yield
|
||||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||||
container.plugins.shutdown()
|
container.plugins.shutdown()
|
||||||
|
container.mcp_servers.shutdown()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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."
|
||||||
|
|
||||||
|
|
||||||
class CredentialStoreError(RuntimeError):
|
class CredentialStoreError(RuntimeError):
|
||||||
@@ -27,10 +28,10 @@ class CredentialResolver(Protocol):
|
|||||||
def validate_provider_credential_id(credential_id: str | None) -> None:
|
def validate_provider_credential_id(credential_id: str | None) -> None:
|
||||||
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
||||||
|
|
||||||
if credential_id and credential_id.casefold().startswith(
|
if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX):
|
||||||
_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.")
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentCredentialResolver:
|
class EnvironmentCredentialResolver:
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ from app.contracts import (
|
|||||||
IndexJob,
|
IndexJob,
|
||||||
IndexRebuildRequest,
|
IndexRebuildRequest,
|
||||||
IndexStatus,
|
IndexStatus,
|
||||||
|
McpServer,
|
||||||
|
McpServerCreateRequest,
|
||||||
|
McpServerListResponse,
|
||||||
|
McpServerSecretStatus,
|
||||||
|
McpServerSecretWriteRequest,
|
||||||
|
McpServerTrustRequest,
|
||||||
|
McpServerUpdateRequest,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
ModelEventType,
|
ModelEventType,
|
||||||
Note,
|
Note,
|
||||||
@@ -72,6 +79,7 @@ from app.agent import AgentCapacityError, AgentRunNotFoundError
|
|||||||
from app.container import container
|
from app.container import container
|
||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
from app.extensions import ExtensionError
|
from app.extensions import ExtensionError
|
||||||
|
from app.extensions.mcp_registry import McpRegistryError
|
||||||
from app.providers.registry import ProviderNotFoundError
|
from app.providers.registry import ProviderNotFoundError
|
||||||
from app.providers.factory import UnsupportedProviderError
|
from app.providers.factory import UnsupportedProviderError
|
||||||
from app.providers.base import ProviderError
|
from app.providers.base import ProviderError
|
||||||
@@ -91,6 +99,21 @@ 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):
|
||||||
|
"""MCP process operations wait on stdio and must not block the API event loop."""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(operation)
|
||||||
|
except McpRegistryError as exc:
|
||||||
|
raise ApiError(exc.status_code, exc.code, exc.message) from exc
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -481,6 +504,63 @@ async def uninstall_skill(skill_id: str) -> OperationResponse:
|
|||||||
return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled")
|
return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled")
|
||||||
|
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
|
||||||
|
async def update_mcp_server(server_id: str, request: McpServerUpdateRequest) -> McpServer:
|
||||||
|
return await mcp_call_async(lambda: container.mcp_servers.update(server_id, request))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mcp/servers/{server_id}", response_model=OperationResponse, tags=["MCP Servers"])
|
||||||
|
async def delete_mcp_server(server_id: str) -> OperationResponse:
|
||||||
|
await mcp_call_async(lambda: container.mcp_servers.delete(server_id))
|
||||||
|
return OperationResponse(status="completed", resource_id=server_id, message="deleted")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/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(lambda: container.mcp_servers.trust(server_id, request.command_digest))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mcp/servers/{server_id}/test", response_model=McpServer, tags=["MCP Servers"])
|
||||||
|
async def test_mcp_server(server_id: str) -> McpServer:
|
||||||
|
return await mcp_call_async(lambda: container.mcp_servers.test(server_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mcp/servers/{server_id}/enable", response_model=McpServer, tags=["MCP Servers"])
|
||||||
|
async def enable_mcp_server(server_id: str) -> McpServer:
|
||||||
|
return await mcp_call_async(lambda: container.mcp_servers.enable(server_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mcp/servers/{server_id}/disable", response_model=McpServer, tags=["MCP Servers"])
|
||||||
|
async def disable_mcp_server(server_id: str) -> McpServer:
|
||||||
|
return await mcp_call_async(lambda: container.mcp_servers.disable(server_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mcp/servers/{server_id}/secrets/{key}", response_model=McpServerSecretStatus, tags=["MCP Servers"])
|
||||||
|
async def put_mcp_server_secret(server_id: str, key: str, request: McpServerSecretWriteRequest) -> McpServerSecretStatus:
|
||||||
|
return mcp_call(lambda: container.mcp_servers.put_secret(server_id, key, request.secret.get_secret_value()))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mcp/servers/{server_id}/secrets/{key}", response_model=McpServerSecretStatus, tags=["MCP Servers"])
|
||||||
|
async def delete_mcp_server_secret(server_id: str, key: str) -> McpServerSecretStatus:
|
||||||
|
return mcp_call(lambda: container.mcp_servers.delete_secret(server_id, key))
|
||||||
|
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
@router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"])
|
@router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"])
|
||||||
async def list_plugins() -> PluginListResponse:
|
async def list_plugins() -> PluginListResponse:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agent.tools import ToolRegistry
|
||||||
|
from app.config import BACKEND_DIR, get_settings
|
||||||
|
from app.contracts import McpServerCreateRequest, McpServerUpdateRequest
|
||||||
|
from app.extensions.mcp_registry import McpRegistryError, McpServerRegistry
|
||||||
|
from app.providers.credentials import EncryptedCredentialStore
|
||||||
|
|
||||||
|
SERVER = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo" / "server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def request(**overrides) -> McpServerCreateRequest:
|
||||||
|
values = {
|
||||||
|
"name": "Echo MCP",
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [str(SERVER)],
|
||||||
|
"permissions": ["notes.read", "secrets.use"],
|
||||||
|
"secret_environment_keys": ["TEST_MCP_SECRET"],
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return McpServerCreateRequest(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def registry(*, launch: bool = True) -> McpServerRegistry:
|
||||||
|
return McpServerRegistry(
|
||||||
|
ToolRegistry(),
|
||||||
|
EncryptedCredentialStore(),
|
||||||
|
get_settings().data_dir,
|
||||||
|
allow_process_launch=launch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_requires_current_trust_and_never_returns_secret() -> None:
|
||||||
|
service = registry()
|
||||||
|
created = service.create(request())
|
||||||
|
assert created.trusted is False
|
||||||
|
assert created.secret_environment == {"TEST_MCP_SECRET": False}
|
||||||
|
|
||||||
|
service.put_secret(created.server_id, "TEST_MCP_SECRET", "do-not-return")
|
||||||
|
configured = service.get(created.server_id)
|
||||||
|
assert configured.secret_environment == {"TEST_MCP_SECRET": True}
|
||||||
|
assert "do-not-return" not in configured.model_dump_json()
|
||||||
|
|
||||||
|
with pytest.raises(McpRegistryError, match="approve"):
|
||||||
|
service.test(created.server_id)
|
||||||
|
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
tested = service.test(created.server_id)
|
||||||
|
assert tested.status == "stopped"
|
||||||
|
assert tested.last_test_succeeded is True
|
||||||
|
assert tested.tools_count > 0
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_disables_server_and_revokes_command_trust() -> None:
|
||||||
|
service = registry()
|
||||||
|
created = service.create(request(secret_environment_keys=[]))
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
enabled = service.enable(created.server_id)
|
||||||
|
assert enabled.enabled is True
|
||||||
|
assert any(
|
||||||
|
item.name.startswith(f"mcp.{created.server_id}.")
|
||||||
|
for item in service.tools.definitions()
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = service.update(
|
||||||
|
created.server_id,
|
||||||
|
McpServerUpdateRequest(
|
||||||
|
**request(name="Changed", secret_environment_keys=[]).model_dump()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert updated.enabled is False
|
||||||
|
assert updated.trusted is False
|
||||||
|
assert not any(
|
||||||
|
item.name.startswith(f"mcp.{created.server_id}.")
|
||||||
|
for item in service.tools.definitions()
|
||||||
|
)
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_process_launch_even_after_approval() -> None:
|
||||||
|
service = registry(launch=False)
|
||||||
|
created = service.create(request(secret_environment_keys=[]))
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.enable(created.server_id)
|
||||||
|
assert error.value.code == "MCP_SANDBOX_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_stdio_transport_is_explicitly_reserved() -> None:
|
||||||
|
service = registry()
|
||||||
|
created = service.create(
|
||||||
|
request(
|
||||||
|
transport="streamable_http",
|
||||||
|
command="https://example.invalid/mcp",
|
||||||
|
secret_environment_keys=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.test(created.server_id)
|
||||||
|
assert error.value.code == "MCP_TRANSPORT_UNSUPPORTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_server_is_restored_from_persisted_registry() -> None:
|
||||||
|
first = registry()
|
||||||
|
created = first.create(request(secret_environment_keys=[]))
|
||||||
|
first.trust(created.server_id, created.command_digest)
|
||||||
|
first.enable(created.server_id)
|
||||||
|
first.shutdown()
|
||||||
|
|
||||||
|
restored = registry()
|
||||||
|
restored.restore_enabled()
|
||||||
|
current = restored.get(created.server_id)
|
||||||
|
assert current.enabled is True
|
||||||
|
assert current.status == "ready"
|
||||||
|
assert any(
|
||||||
|
item.name.startswith(f"mcp.{created.server_id}.")
|
||||||
|
for item in restored.tools.definitions()
|
||||||
|
)
|
||||||
|
restored.shutdown()
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||||
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
||||||
|
- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md)
|
||||||
- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md)
|
- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md)
|
||||||
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
||||||
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
||||||
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
||||||
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
|
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
|
||||||
|
| MCP Server | GET/POST | `/api/mcp/servers` | 已实现(C.1/P0) | 列出、创建独立 MCP Server 配置 |
|
||||||
|
| MCP Server | GET/PUT/DELETE | `/api/mcp/servers/{server_id}` | 已实现(C.1/P0) | 读取、修改、删除独立配置 |
|
||||||
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/trust` | 已实现(C.1/P0) | 确认当前可执行配置摘要 |
|
||||||
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/test` | 已实现(C.1/P0) | 隔离启动、握手、发现工具后退出 |
|
||||||
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/enable`、`disable` | 已实现(C.1/P0) | 控制 Host 与动态 Tool 生命周期 |
|
||||||
|
| MCP Server | PUT/DELETE | `/api/mcp/servers/{server_id}/secrets/{key}` | 已实现(C.1/P0) | 写入或删除加密环境变量 |
|
||||||
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
|
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
|
||||||
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
||||||
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
||||||
@@ -647,6 +653,18 @@ MCP_TOOL_SCHEMA_INVALID
|
|||||||
MCP_TOOL_CALL_FAILED
|
MCP_TOOL_CALL_FAILED
|
||||||
MCP_TOOL_RESULT_TOO_LARGE
|
MCP_TOOL_RESULT_TOO_LARGE
|
||||||
MCP_TRUST_APPROVAL_REQUIRED
|
MCP_TRUST_APPROVAL_REQUIRED
|
||||||
|
MCP_TRUST_DIGEST_STALE
|
||||||
|
MCP_SANDBOX_REQUIRED
|
||||||
|
MCP_TRANSPORT_UNSUPPORTED
|
||||||
|
MCP_SERVER_NOT_FOUND
|
||||||
|
MCP_SERVER_NAME_INVALID
|
||||||
|
MCP_SERVER_ALREADY_ENABLED
|
||||||
|
MCP_REGISTRY_WRITE_FAILED
|
||||||
|
MCP_SECRET_REQUIRED
|
||||||
|
MCP_SECRET_NOT_DECLARED
|
||||||
|
MCP_SECRET_STORE_ERROR
|
||||||
|
MCP_ENVIRONMENT_INVALID
|
||||||
|
MCP_PERMISSION_INVALID
|
||||||
PLUGIN_COMMAND_NOT_FOUND
|
PLUGIN_COMMAND_NOT_FOUND
|
||||||
PLUGIN_COMMAND_CONFLICT
|
PLUGIN_COMMAND_CONFLICT
|
||||||
PLUGIN_COMMAND_INVALID
|
PLUGIN_COMMAND_INVALID
|
||||||
@@ -670,6 +688,14 @@ PLUGIN_STORAGE_ERROR
|
|||||||
CREDENTIAL_NAMESPACE_RESERVED
|
CREDENTIAL_NAMESPACE_RESERVED
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7.8 独立 MCP Server Registry(C.1)
|
||||||
|
|
||||||
|
独立 Server 不依附 Plugin Manifest,配置持久化于 `APP_DATA_DIR/mcp/servers.json`,敏感环境变量只以 `mcp.*` 引用进入加密凭据存储。响应仅返回每个 Secret 是否已配置,不返回明文。动态工具使用 `mcp.{server_id}.{remote_tool}` 命名空间,来源标记为 `mcp_server`,仍通过统一 Tool Registry、Permission Manager 与 Agent Trace。
|
||||||
|
|
||||||
|
P0 只真实支持 `stdio`。`streamable_http` 与 `sse` 已作为后续 Contract 枚举保留,但测试或启用会返回 `501 MCP_TRANSPORT_UNSUPPORTED`,前端不可伪装为可用。命令始终以 executable 与 args 数组通过 `shell=False` 启动;普通环境变量和加密 Secret 显式注入,不继承 Provider Key、数据库或 Vault 路径。
|
||||||
|
|
||||||
|
创建或编辑配置后,调用方必须向 `/trust` 回传服务端计算的 `command_digest`。后端只接受与当前 transport、command、args、环境变量键值及权限完全一致的摘要;配置变化会撤销旧信任。测试连接同样会实际启动进程,因此也要求确认。当前 Python Host 仅在 `APP_ENVIRONMENT=development` 时允许启动;其他环境返回 `403 MCP_SANDBOX_REQUIRED`,等待第三阶段桌面端沙箱接管。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Provider Adapter 扩展
|
## 8. Provider Adapter 扩展
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 独立 MCP Server 配置中心开发说明
|
||||||
|
|
||||||
|
> 更新日期:2026-09-03。本文记录第二阶段 C.1 的 P0 实现;它与 Plugin 自带的 MCP Host 是两个并列入口。
|
||||||
|
|
||||||
|
## 1. 已实现范围
|
||||||
|
|
||||||
|
- 独立 Server 的创建、读取、编辑和删除;
|
||||||
|
- stdio 命令、参数、普通环境变量、加密环境变量及超时配置;
|
||||||
|
- 命令摘要确认、测试连接、启用、停用和异常状态展示;
|
||||||
|
- MCP initialize、`tools/list` 与动态 Tool 注册,工具命名为 `mcp.{server_id}.{tool}`;
|
||||||
|
- 启用状态持久化与开发服务重启恢复;
|
||||||
|
- 前端独立“MCP”导航与配置弹窗,提供 stdio/uvx 模板;
|
||||||
|
- Streamable HTTP 与旧 SSE 仅作为后续选项展示为禁用,不属于本次完成范围。
|
||||||
|
|
||||||
|
## 2. 数据与 Secret
|
||||||
|
|
||||||
|
普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。Secret 使用 `mcp.{server_id}.{key_hash}` 作为内部引用写入现有 Fernet 凭据存储;API 和前端只看到 `configured: true/false`。删除 Server 或移除 Secret 键时同步清理密文。
|
||||||
|
|
||||||
|
前端 Secret 输入使用密码框,提交后立即清空,不写入 localStorage、普通配置 JSON 或日志。`mcp.*` 同 `plugin.*` 一样属于保留凭据命名空间,Provider 配置与通用凭据 API 无权读取。
|
||||||
|
|
||||||
|
## 3. 启动安全边界
|
||||||
|
|
||||||
|
命令不经过 Shell,管道、重定向和拼接字符串不会被解释。Host 只继承启动所需的系统变量,再叠加用户显式配置;`uvx` 可隔离 Python 依赖,但不能限制文件、网络和系统调用。
|
||||||
|
|
||||||
|
后端会对影响执行的配置计算 SHA-256 摘要。测试或启用前,用户必须确认并回传当前摘要;修改配置会立即撤销旧确认。由于 Python Host 尚无 OS 沙箱,非开发环境硬拒绝启动。第三阶段由 Tauri/Rust Host 提供平台级隔离后再替换这道临时门禁。
|
||||||
|
|
||||||
|
## 4. 测试与启动
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
uv run pytest -q tests/test_mcp_registry.py
|
||||||
|
uv run uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
cd ../frontend
|
||||||
|
npm run type-check
|
||||||
|
npm test
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开知识库后进入左侧“MCP”。保存配置,按提示确认命令,先执行“测试连接”;成功后再启用。默认模板 `uvx mcp-server-fetch` 仅为配置示例,首次下载是否联网由本机 uv 缓存与网络环境决定。
|
||||||
|
|
||||||
|
## 5. 后续增量
|
||||||
|
|
||||||
|
- P1:Streamable HTTP 连接、认证 Header 与重连策略;
|
||||||
|
- 兼容项:仅在确有旧服务需求时增加 SSE;
|
||||||
|
- 第三阶段前:把命令确认与进程创建迁移至 Tauri/Rust 沙箱;
|
||||||
|
- 增加面向真实第三方 Server 的兼容矩阵,不用单一 Fixture 代表协议全兼容。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Search, Setting } from '@element-plus/icons-vue'
|
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import AppIcon from './AppIcon.vue'
|
import AppIcon from './AppIcon.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -16,6 +16,7 @@ const navItems = [
|
|||||||
{ name: 'tasks', icon: CircleCheck, label: '任务' },
|
{ name: 'tasks', icon: CircleCheck, label: '任务' },
|
||||||
{ name: 'skills', icon: Lightning, label: 'Skill' },
|
{ name: 'skills', icon: Lightning, label: 'Skill' },
|
||||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||||
|
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
|
||||||
{ name: 'themes', icon: Brush, label: '主题' },
|
{ name: 'themes', icon: Brush, label: '主题' },
|
||||||
{ name: 'settings', icon: Setting, label: '设置' },
|
{ name: 'settings', icon: Setting, label: '设置' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export interface ToolDefinition {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
parameters: Record<string, unknown>
|
parameters: Record<string, unknown>
|
||||||
source?: 'builtin' | 'plugin'
|
source?: 'builtin' | 'plugin' | 'mcp_server'
|
||||||
plugin_id?: string
|
plugin_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +535,38 @@ export interface OperationResponse {
|
|||||||
message?: string | null
|
message?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpServerTransport = 'stdio' | 'streamable_http' | 'sse'
|
||||||
|
export type McpServerState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
|
||||||
|
|
||||||
|
export interface McpServerInput {
|
||||||
|
name: string
|
||||||
|
transport: McpServerTransport
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
environment: Record<string, string>
|
||||||
|
secret_environment_keys: string[]
|
||||||
|
permissions: string[]
|
||||||
|
startup_timeout_seconds: number
|
||||||
|
tool_timeout_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServer extends Omit<McpServerInput, 'secret_environment_keys'> {
|
||||||
|
server_id: string
|
||||||
|
secret_environment: Record<string, boolean>
|
||||||
|
enabled: boolean
|
||||||
|
trusted: boolean
|
||||||
|
command_digest: string
|
||||||
|
command_summary: string
|
||||||
|
status: McpServerState
|
||||||
|
tools_count: number
|
||||||
|
protocol_version?: string | null
|
||||||
|
remote_server_name?: string | null
|
||||||
|
remote_server_version?: string | null
|
||||||
|
error?: string | null
|
||||||
|
last_tested_at?: string | null
|
||||||
|
last_test_succeeded?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiNoteBlock {
|
export interface ApiNoteBlock {
|
||||||
block_id: string
|
block_id: string
|
||||||
note_id: string
|
note_id: string
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||||
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
|
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
|
||||||
|
import * as service from '@/services/mcpServerService'
|
||||||
|
|
||||||
|
const servers = ref<McpServer[]>([])
|
||||||
|
const busy = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const argsText = ref('')
|
||||||
|
const environmentText = ref('{}')
|
||||||
|
const secretKeysText = ref('')
|
||||||
|
const permissionsText = ref('')
|
||||||
|
const secretDrafts = reactive<Record<string, string>>({})
|
||||||
|
const form = reactive<McpServerInput>({
|
||||||
|
name: '', transport: 'stdio', command: '', args: [], environment: {},
|
||||||
|
secret_environment_keys: [], permissions: [], startup_timeout_seconds: 15, tool_timeout_seconds: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error.value = ''
|
||||||
|
try { servers.value = await service.listMcpServers() }
|
||||||
|
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, { name: '', transport: 'stdio', command: '', args: [], environment: {}, secret_environment_keys: [], permissions: [], startup_timeout_seconds: 15, tool_timeout_seconds: 30 })
|
||||||
|
argsText.value = ''; environmentText.value = '{}'; secretKeysText.value = ''; permissionsText.value = ''
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(server: McpServer) {
|
||||||
|
editingId.value = server.server_id
|
||||||
|
Object.assign(form, {
|
||||||
|
name: server.name, transport: server.transport, command: server.command,
|
||||||
|
args: [...server.args], environment: { ...server.environment },
|
||||||
|
secret_environment_keys: Object.keys(server.secret_environment), permissions: [...server.permissions],
|
||||||
|
startup_timeout_seconds: server.startup_timeout_seconds,
|
||||||
|
tool_timeout_seconds: server.tool_timeout_seconds,
|
||||||
|
})
|
||||||
|
argsText.value = server.args.join('\n')
|
||||||
|
environmentText.value = JSON.stringify(server.environment, null, 2)
|
||||||
|
secretKeysText.value = Object.keys(server.secret_environment).join('\n')
|
||||||
|
permissionsText.value = server.permissions.join(', ')
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(transport: McpServerTransport) {
|
||||||
|
if (transport !== 'stdio') return
|
||||||
|
form.transport = 'stdio'; form.command = 'uvx'; argsText.value = 'mcp-server-fetch'
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload(): McpServerInput {
|
||||||
|
let environment: Record<string, string>
|
||||||
|
try { environment = JSON.parse(environmentText.value || '{}') }
|
||||||
|
catch { throw new Error('普通环境变量必须是 JSON 对象') }
|
||||||
|
if (!environment || Array.isArray(environment) || typeof environment !== 'object') throw new Error('普通环境变量必须是 JSON 对象')
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(), command: form.command.trim(),
|
||||||
|
args: argsText.value.split('\n').map(value => value.trim()).filter(Boolean),
|
||||||
|
environment,
|
||||||
|
secret_environment_keys: secretKeysText.value.split(/[\n,]/).map(value => value.trim()).filter(Boolean),
|
||||||
|
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
const input = payload()
|
||||||
|
if (!input.name || !input.command) throw new Error('请填写服务器名称和可执行命令')
|
||||||
|
busy.value = 'save'
|
||||||
|
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||||
|
dialogOpen.value = false
|
||||||
|
await load()
|
||||||
|
} catch (cause) { error.value = message(cause, '保存失败') }
|
||||||
|
finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(server: McpServer): Promise<McpServer | null> {
|
||||||
|
if (server.trusted) return server
|
||||||
|
const accepted = confirm(`即将允许本机启动以下命令:\n\n${server.command_summary}\n\n当前 Python Host 没有系统级沙箱,仅应运行可信服务器。是否继续?`)
|
||||||
|
if (!accepted) return null
|
||||||
|
return service.trustMcpServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(server: McpServer) { await act(server, 'test', async current => service.testMcpServer(current.server_id)) }
|
||||||
|
async function toggle(server: McpServer) { await act(server, 'toggle', async current => current.enabled ? service.disableMcpServer(current.server_id) : service.enableMcpServer(current.server_id)) }
|
||||||
|
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
|
||||||
|
busy.value = `${action}:${server.server_id}`; error.value = ''
|
||||||
|
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
|
||||||
|
catch (cause) { error.value = message(cause, '操作失败') }
|
||||||
|
finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(server: McpServer) {
|
||||||
|
if (!confirm(`删除“${server.name}”及其加密凭据?`)) return
|
||||||
|
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
|
||||||
|
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSecret(server: McpServer, key: string) {
|
||||||
|
const value = secretDrafts[`${server.server_id}:${key}`]?.trim()
|
||||||
|
if (!value) return
|
||||||
|
try { busy.value = `secret:${server.server_id}:${key}`; await service.putMcpServerSecret(server.server_id, key, value); secretDrafts[`${server.server_id}:${key}`] = ''; await load() }
|
||||||
|
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(cause: unknown, fallback: string) { return cause instanceof Error ? cause.message : fallback }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="feature-page mcp-page">
|
||||||
|
<header class="feature-header">
|
||||||
|
<div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接、凭据与工具生命周期。</p></div>
|
||||||
|
<div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div>
|
||||||
|
</header>
|
||||||
|
<div class="notice-banner">开发阶段仅开放 stdio。uvx 负责依赖隔离,但不是安全沙箱;生产环境将在桌面端沙箱接入前禁止启动本机进程。</div>
|
||||||
|
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||||
|
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加一个 stdio Server,保存后可测试连接并启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
|
||||||
|
<div v-else class="server-list">
|
||||||
|
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
|
||||||
|
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
|
||||||
|
<div class="metadata"><span>{{ server.transport }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '命令已确认' : '等待确认命令' }}</span><span v-if="server.last_test_succeeded">最近测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
|
||||||
|
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
|
||||||
|
<div v-if="Object.keys(server.secret_environment).length" class="secrets"><label v-for="(configured, key) in server.secret_environment" :key="key"><span>{{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key)">保存</button></span></label></div>
|
||||||
|
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
|
||||||
|
<form class="modal-card" @submit.prevent="save">
|
||||||
|
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="dialogOpen = false">×</button></header>
|
||||||
|
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
|
||||||
|
<div class="template-row"><span>服务器配置</span><button type="button" class="template active" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" disabled>Streamable HTTP(后续)</button><button type="button" class="template" disabled>SSE(兼容项)</button></div>
|
||||||
|
<label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label>
|
||||||
|
<label>参数(每行一项)<textarea v-model="argsText" rows="4" placeholder="mcp-server-fetch"></textarea></label>
|
||||||
|
<div class="two-columns"><label>普通环境变量(JSON)<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div>
|
||||||
|
<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>
|
||||||
|
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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(160px,.6fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }
|
||||||
|
.modal-card { width: min(760px, 100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }
|
||||||
|
.modal-card header, .modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input, .modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-family-mono); font-size: var(--font-size-sm); }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); }.template.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
|
||||||
|
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } }
|
||||||
|
@media (max-width: 720px) { .two-columns, .secrets label { grid-template-columns: 1fr; }.card-actions { flex-wrap: wrap; } }
|
||||||
|
</style>
|
||||||
@@ -44,6 +44,12 @@ const routes = [
|
|||||||
component: () => import('@/features/skills/SkillsView.vue'),
|
component: () => import('@/features/skills/SkillsView.vue'),
|
||||||
meta: { title: 'Skill 管理', requiresVault: true },
|
meta: { title: 'Skill 管理', requiresVault: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/extensions/mcp',
|
||||||
|
name: 'mcp-servers',
|
||||||
|
component: () => import('@/features/mcp/McpServersView.vue'),
|
||||||
|
meta: { title: 'MCP 服务器', requiresVault: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/extensions/plugins',
|
path: '/extensions/plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * as chatService from './chatService'
|
|||||||
export * as agentService from './agentService'
|
export * as agentService from './agentService'
|
||||||
export * as skillService from './skillService'
|
export * as skillService from './skillService'
|
||||||
export * as pluginService from './pluginService'
|
export * as pluginService from './pluginService'
|
||||||
|
export * as mcpServerService from './mcpServerService'
|
||||||
export * as providerService from './providerService'
|
export * as providerService from './providerService'
|
||||||
export * as taskService from './taskService'
|
export * as taskService from './taskService'
|
||||||
export * as indexService from './indexService'
|
export * as indexService from './indexService'
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import apiClient from './apiClient'
|
||||||
|
import type { McpServer, McpServerInput, OperationResponse } from '@/contracts'
|
||||||
|
|
||||||
|
const base = '/api/mcp/servers'
|
||||||
|
|
||||||
|
export async function listMcpServers(): Promise<McpServer[]> {
|
||||||
|
return (await apiClient.get<{ items: McpServer[] }>(base)).items
|
||||||
|
}
|
||||||
|
export const createMcpServer = (input: McpServerInput) => apiClient.post<McpServer>(base, input)
|
||||||
|
export const updateMcpServer = (id: string, input: McpServerInput) => apiClient.put<McpServer>(`${base}/${id}`, input)
|
||||||
|
export const deleteMcpServer = (id: string) => apiClient.delete<OperationResponse>(`${base}/${id}`)
|
||||||
|
export const trustMcpServer = (server: McpServer) => apiClient.post<McpServer>(`${base}/${server.server_id}/trust`, { command_digest: server.command_digest })
|
||||||
|
export const testMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/test`)
|
||||||
|
export const enableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/enable`)
|
||||||
|
export const disableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/disable`)
|
||||||
|
export const putMcpServerSecret = (id: string, key: string, secret: string) => apiClient.put(`${base}/${id}/secrets/${encodeURIComponent(key)}`, { secret })
|
||||||
|
export const deleteMcpServerSecret = (id: string, key: string) => apiClient.delete(`${base}/${id}/secrets/${encodeURIComponent(key)}`)
|
||||||
Reference in New Issue
Block a user