feat(mcp): 完善独立 MCP 服务器配置中心与连接生命周期管理 #13
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 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.config import BACKEND_DIR, get_settings
|
||||
from app.extensions import PluginRuntime, SkillRuntime
|
||||
from app.extensions.mcp_registry import McpServerRegistry
|
||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
@@ -22,6 +23,7 @@ class ApplicationContainer:
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
plugins: PluginRuntime
|
||||
mcp_servers: McpServerRegistry
|
||||
agent: AgentRuntime
|
||||
|
||||
|
||||
@@ -61,6 +63,14 @@ def build_container() -> ApplicationContainer:
|
||||
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "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.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
|
||||
skills.enable("knowledge-assistant")
|
||||
@@ -81,6 +91,7 @@ def build_container() -> ApplicationContainer:
|
||||
permissions=permissions,
|
||||
skills=skills,
|
||||
plugins=plugins,
|
||||
mcp_servers=mcp_servers,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ class ToolDefinition(Contract):
|
||||
description: str
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
permission: str | None = None
|
||||
source: Literal["builtin", "plugin"] = "builtin"
|
||||
source: Literal["builtin", "plugin", "mcp_server"] = "builtin"
|
||||
|
||||
|
||||
class ToolCall(Contract):
|
||||
@@ -486,6 +486,72 @@ class PluginHostStatus(Contract):
|
||||
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):
|
||||
command_palette = "command_palette"
|
||||
context_menu = "context_menu"
|
||||
|
||||
@@ -74,12 +74,14 @@ class McpStdioClient:
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
environment: dict[str, str] | None = None,
|
||||
on_seen: Callable[[], None],
|
||||
on_broken: Callable[[str], None],
|
||||
on_tools_changed: Callable[[], None],
|
||||
) -> None:
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.environment = environment or {}
|
||||
self.on_seen = on_seen
|
||||
self.on_broken = on_broken
|
||||
self.on_tools_changed = on_tools_changed
|
||||
@@ -99,6 +101,7 @@ class McpStdioClient:
|
||||
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
|
||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
||||
environment = _subprocess_environment()
|
||||
environment.update(self.environment)
|
||||
environment.setdefault("PYTHONUNBUFFERED", "1")
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
@@ -383,6 +386,10 @@ class McpBridge:
|
||||
package_path: Path,
|
||||
declared_permissions: list[str],
|
||||
on_unavailable: Callable[[str, str], None],
|
||||
*,
|
||||
command_override: list[str] | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
tool_source: str = "plugin",
|
||||
) -> list[McpDiscoveredTool]:
|
||||
if backend.transport != "stdio":
|
||||
raise McpBridgeError(
|
||||
@@ -390,7 +397,7 @@ class McpBridge:
|
||||
"Phase C only supports the MCP stdio transport.",
|
||||
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)
|
||||
status = PluginHostStatus(
|
||||
plugin_id=plugin_id,
|
||||
@@ -420,6 +427,7 @@ class McpBridge:
|
||||
client = McpStdioClient(
|
||||
command,
|
||||
cwd=package_path,
|
||||
environment=environment,
|
||||
on_seen=seen,
|
||||
on_broken=broken,
|
||||
on_tools_changed=tools_changed,
|
||||
@@ -470,7 +478,7 @@ class McpBridge:
|
||||
status.server_version = _optional_string(server_info.get("version"))
|
||||
client.notify("notifications/initialized")
|
||||
discovered = self._discover_tools(
|
||||
plugin_id, client, backend, declared_permissions
|
||||
plugin_id, client, backend, declared_permissions, tool_source
|
||||
)
|
||||
status.status = PluginHostState.ready
|
||||
status.tools_count = len(discovered)
|
||||
@@ -601,6 +609,7 @@ class McpBridge:
|
||||
client: McpStdioClient,
|
||||
backend: PluginBackend,
|
||||
declared_permissions: list[str],
|
||||
tool_source: str,
|
||||
) -> list[McpDiscoveredTool]:
|
||||
discovered: list[McpDiscoveredTool] = []
|
||||
cursor: str | None = None
|
||||
@@ -620,7 +629,7 @@ class McpBridge:
|
||||
)
|
||||
for raw in raw_tools:
|
||||
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:
|
||||
raise McpBridgeError(
|
||||
@@ -648,7 +657,10 @@ class McpBridge:
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
if not isinstance(raw, dict):
|
||||
raise McpBridgeError(
|
||||
@@ -712,7 +724,7 @@ class McpBridge:
|
||||
description=description if isinstance(description, str) else remote_name,
|
||||
parameters=schema,
|
||||
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
|
||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
|
||||
|
||||
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}$")
|
||||
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
|
||||
_MCP_CREDENTIAL_PREFIX = "mcp."
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
@@ -27,10 +28,10 @@ class CredentialResolver(Protocol):
|
||||
def validate_provider_credential_id(credential_id: str | None) -> None:
|
||||
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
||||
|
||||
if credential_id and credential_id.casefold().startswith(
|
||||
_PLUGIN_CREDENTIAL_PREFIX
|
||||
):
|
||||
if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX):
|
||||
raise CredentialStoreError("Credential namespace is reserved for Plugin settings.")
|
||||
if credential_id and credential_id.casefold().startswith(_MCP_CREDENTIAL_PREFIX):
|
||||
raise CredentialStoreError("Credential namespace is reserved for MCP settings.")
|
||||
|
||||
|
||||
class EnvironmentCredentialResolver:
|
||||
|
||||
@@ -21,6 +21,13 @@ from app.contracts import (
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
IndexStatus,
|
||||
McpServer,
|
||||
McpServerCreateRequest,
|
||||
McpServerListResponse,
|
||||
McpServerSecretStatus,
|
||||
McpServerSecretWriteRequest,
|
||||
McpServerTrustRequest,
|
||||
McpServerUpdateRequest,
|
||||
ModelEvent,
|
||||
ModelEventType,
|
||||
Note,
|
||||
@@ -72,6 +79,7 @@ from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
from app.errors import ApiError
|
||||
from app.extensions import ExtensionError
|
||||
from app.extensions.mcp_registry import McpRegistryError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
from app.providers.base import ProviderError
|
||||
@@ -91,6 +99,21 @@ 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."""
|
||||
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:
|
||||
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")
|
||||
|
||||
|
||||
# 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
|
||||
@router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"])
|
||||
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)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.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)
|
||||
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
||||
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
||||
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
||||
| 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 | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
||||
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
||||
@@ -647,6 +653,18 @@ MCP_TOOL_SCHEMA_INVALID
|
||||
MCP_TOOL_CALL_FAILED
|
||||
MCP_TOOL_RESULT_TOO_LARGE
|
||||
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_CONFLICT
|
||||
PLUGIN_COMMAND_INVALID
|
||||
@@ -670,6 +688,14 @@ PLUGIN_STORAGE_ERROR
|
||||
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 扩展
|
||||
|
||||
@@ -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">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -16,6 +16,7 @@ const navItems = [
|
||||
{ name: 'tasks', icon: CircleCheck, label: '任务' },
|
||||
{ name: 'skills', icon: Lightning, label: 'Skill' },
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
|
||||
{ name: 'themes', icon: Brush, label: '主题' },
|
||||
{ name: 'settings', icon: Setting, label: '设置' },
|
||||
]
|
||||
|
||||
@@ -193,7 +193,7 @@ export interface ToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
source?: 'builtin' | 'plugin'
|
||||
source?: 'builtin' | 'plugin' | 'mcp_server'
|
||||
plugin_id?: string
|
||||
}
|
||||
|
||||
@@ -535,6 +535,38 @@ export interface OperationResponse {
|
||||
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 {
|
||||
block_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'),
|
||||
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',
|
||||
name: 'plugins',
|
||||
|
||||
@@ -8,6 +8,7 @@ export * as chatService from './chatService'
|
||||
export * as agentService from './agentService'
|
||||
export * as skillService from './skillService'
|
||||
export * as pluginService from './pluginService'
|
||||
export * as mcpServerService from './mcpServerService'
|
||||
export * as providerService from './providerService'
|
||||
export * as taskService from './taskService'
|
||||
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