feat(mcp): add standalone server registry
Implement C.1 stdio MCP server CRUD, encrypted environment secrets, command digest approval, connection tests, lifecycle recovery, and dynamic tool registration. Add the standalone frontend configuration center, contracts, regression tests, and development documentation.
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user