From fc4b7b9495299b2a9971aba26cf5008a5b4ccd86 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 11:32:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(extension):=20=E6=8E=A5=E5=85=A5=20std?= =?UTF-8?q?io=20MCP=20Plugin=20Host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/agent/runtime.py | 8 +- backend/app/agent/tools.py | 62 +- backend/app/contracts.py | 26 + backend/app/extensions/__init__.py | 10 +- backend/app/extensions/mcp.py | 746 ++++++++++++++++++ backend/app/extensions/runtime.py | 264 +++++-- backend/app/main.py | 12 + backend/app/routes.py | 47 +- .../extensions/fixtures/mcp-echo/plugin.yaml | 21 + .../extensions/fixtures/mcp-echo/server.py | 183 +++++ backend/tests/test_api.py | 2 + backend/tests/test_extension_core.py | 212 ++++- frontend/src/contracts/index.ts | 25 +- frontend/src/services/pluginService.ts | 10 +- 14 files changed, 1557 insertions(+), 71 deletions(-) create mode 100644 backend/app/extensions/mcp.py create mode 100644 backend/extensions/fixtures/mcp-echo/plugin.yaml create mode 100644 backend/extensions/fixtures/mcp-echo/server.py diff --git a/backend/app/agent/runtime.py b/backend/app/agent/runtime.py index 3daf87d..e573317 100644 --- a/backend/app/agent/runtime.py +++ b/backend/app/agent/runtime.py @@ -491,7 +491,13 @@ class AgentRuntime: async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult: try: return await asyncio.wait_for( - self.tools.execute(call, ToolExecutionContext(run_id=record.run.run_id)), + self.tools.execute( + call, + ToolExecutionContext( + run_id=record.run.run_id, + tool_call_id=call.tool_call_id, + ), + ), timeout=record.request.tool_timeout_seconds, ) except TimeoutError: diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index a3724d6..df90158 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -1,6 +1,7 @@ """Agent 工具注册与执行边界。""" import inspect +import threading from dataclasses import dataclass from time import perf_counter from typing import Any, Awaitable, Callable @@ -17,6 +18,7 @@ ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any @dataclass(frozen=True, slots=True) class ToolExecutionContext: run_id: str + tool_call_id: str | None = None @dataclass(slots=True) @@ -30,11 +32,21 @@ class ToolNotFoundError(LookupError): pass +class ToolExecutionError(RuntimeError): + """Executor 可预期失败,保留领域错误码而不是折叠成通用异常。""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + class ToolRegistry: """统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。""" def __init__(self) -> None: self._tools: dict[str, RegisteredTool] = {} + self._lock = threading.RLock() def register( self, @@ -42,33 +54,38 @@ class ToolRegistry: arguments_model: type[BaseModel], executor: ToolExecutor, ) -> None: - if definition.name in self._tools: - raise ValueError(f"Tool already registered: {definition.name}") - self._tools[definition.name] = RegisteredTool( - definition=definition, - arguments_model=arguments_model, - executor=executor, - ) + with self._lock: + if definition.name in self._tools: + raise ValueError(f"Tool already registered: {definition.name}") + self._tools[definition.name] = RegisteredTool( + definition=definition, + arguments_model=arguments_model, + executor=executor, + ) def unregister(self, name: str) -> None: - self._tools.pop(name, None) + with self._lock: + self._tools.pop(name, None) def contains(self, name: str) -> bool: - return name in self._tools + with self._lock: + return name in self._tools def get(self, name: str) -> RegisteredTool: - try: - return self._tools[name] - except KeyError as exc: - raise ToolNotFoundError(name) from exc + with self._lock: + try: + return self._tools[name] + except KeyError as exc: + raise ToolNotFoundError(name) from exc def definitions(self, allowed: list[str] | None = None) -> list[ToolDefinition]: names = set(allowed) if allowed is not None else None - return [ - item.definition.model_copy(deep=True) - for name, item in self._tools.items() - if names is None or name in names - ] + with self._lock: + return [ + item.definition.model_copy(deep=True) + for name, item in self._tools.items() + if names is None or name in names + ] async def execute(self, call: ToolCall, context: ToolExecutionContext) -> ToolResult: started = perf_counter() @@ -108,6 +125,15 @@ class ToolRegistry: output=output, duration_ms=round((perf_counter() - started) * 1000), ) + except ToolExecutionError as exc: + return ToolResult( + tool_call_id=call.tool_call_id, + name=call.name, + success=False, + error_code=exc.code, + error_message=exc.message, + duration_ms=round((perf_counter() - started) * 1000), + ) except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。 return ToolResult( tool_call_id=call.tool_call_id, diff --git a/backend/app/contracts.py b/backend/app/contracts.py index bb8bf90..8d1aace 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -417,6 +417,10 @@ class ExtensionInstallRequest(Contract): class PluginBackend(Contract): type: Literal["mcp", "internal_rpc", "none"] = "none" transport: Literal["stdio", "http", "none"] = "none" + command: str | None = None + args: list[str] = Field(default_factory=list) + startup_timeout_seconds: int = Field(default=10, ge=1, le=60) + tool_timeout_seconds: int = Field(default=30, ge=1, le=600) class PluginContribution(Contract): @@ -460,6 +464,28 @@ class PluginListResponse(Contract): items: list[Plugin] = Field(default_factory=list) +class PluginHostState(str, Enum): + stopped = "stopped" + starting = "starting" + ready = "ready" + unhealthy = "unhealthy" + error = "error" + + +class PluginHostStatus(Contract): + plugin_id: str + backend_type: Literal["mcp", "internal_rpc", "none"] + transport: Literal["stdio", "http", "none"] + status: PluginHostState + tools_count: int = 0 + started_at: datetime | None = None + last_seen_at: datetime | None = None + protocol_version: str | None = None + server_name: str | None = None + server_version: str | None = None + error: str | None = None + + class PluginPermissionGrantRequest(Contract): permissions: list[str] = Field(default_factory=list) diff --git a/backend/app/extensions/__init__.py b/backend/app/extensions/__init__.py index 3495110..ed2e570 100644 --- a/backend/app/extensions/__init__.py +++ b/backend/app/extensions/__init__.py @@ -4,5 +4,13 @@ from app.extensions.runtime import ( PluginRuntime, SkillRuntime, ) +from app.extensions.mcp import McpBridge, McpBridgeError -__all__ = ["AgentConfiguration", "ExtensionError", "PluginRuntime", "SkillRuntime"] +__all__ = [ + "AgentConfiguration", + "ExtensionError", + "McpBridge", + "McpBridgeError", + "PluginRuntime", + "SkillRuntime", +] diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py new file mode 100644 index 0000000..2647c20 --- /dev/null +++ b/backend/app/extensions/mcp.py @@ -0,0 +1,746 @@ +"""本地 stdio MCP Bridge。 + +第三方 Server 始终运行在子进程中。Bridge 只把通过校验的 MCP Tool 转换为项目内部 +ToolDefinition/ToolResult,不把 MCP 原始协议泄露给 Agent Runtime 或前端。 +""" + +from __future__ import annotations + +import asyncio +import json +import os +import queue +import subprocess +import threading +from collections import deque +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError + +from app.agent.permissions import KNOWN_PERMISSIONS +from app.agent.tools import ToolExecutionError +from app.contracts import ( + PluginBackend, + PluginHostState, + PluginHostStatus, + ToolDefinition, +) + +MCP_PROTOCOL_VERSION = "2025-11-25" +SUPPORTED_PROTOCOL_VERSIONS = { + MCP_PROTOCOL_VERSION, + "2025-06-18", + "2025-03-26", + "2024-11-05", +} +MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024 +MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024 +MAX_MCP_TOOLS = 500 +MAX_MCP_LIST_PAGES = 100 + + +class McpBridgeError(RuntimeError): + def __init__(self, code: str, message: str, *, status_code: int = 502) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +@dataclass(frozen=True, slots=True) +class McpDiscoveredTool: + remote_name: str + definition: ToolDefinition + + +@dataclass(slots=True) +class _PendingRequest: + response: queue.Queue[dict[str, Any] | BaseException] + + +class McpStdioClient: + """线程驱动的换行分隔 JSON-RPC 客户端,避免阻塞 FastAPI 事件循环。""" + + def __init__( + self, + command: list[str], + *, + cwd: Path, + on_seen: Callable[[], None], + on_broken: Callable[[str], None], + on_tools_changed: Callable[[], None], + ) -> None: + self.command = command + self.cwd = cwd + self.on_seen = on_seen + self.on_broken = on_broken + self.on_tools_changed = on_tools_changed + self.process: subprocess.Popen[str] | None = None + self._write_lock = threading.Lock() + self._pending_lock = threading.Lock() + self._pending: dict[int, _PendingRequest] = {} + self._next_id = 1 + self._stopping = False + # stderr 只在 Host 内部保留有限尾部,不进入 API、Trace 或普通日志。 + self._stderr_tail: deque[str] = deque(maxlen=50) + + def start(self) -> None: + if self.process is not None and self.process.poll() is None: + return + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + environment = _subprocess_environment() + environment.setdefault("PYTHONUNBUFFERED", "1") + try: + self.process = subprocess.Popen( + self.command, + cwd=self.cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + shell=False, + env=environment, + creationflags=creation_flags, + ) + except OSError as exc: + raise McpBridgeError( + "PLUGIN_HOST_START_FAILED", + f"Cannot start MCP server process: {exc}", + status_code=503, + ) from exc + threading.Thread(target=self._stdout_loop, daemon=True).start() + threading.Thread(target=self._stderr_loop, daemon=True).start() + + def request( + self, + method: str, + params: dict[str, Any], + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: + request_id, pending = self.begin_request(method, params) + return self.wait_response( + request_id, + pending, + timeout=timeout, + timeout_code=timeout_code, + response_error_code=response_error_code, + ) + + def begin_request( + self, method: str, params: dict[str, Any] + ) -> tuple[int, _PendingRequest]: + self._ensure_running() + with self._pending_lock: + request_id = self._next_id + self._next_id += 1 + pending = _PendingRequest(response=queue.Queue(maxsize=1)) + self._pending[request_id] = pending + try: + self._send( + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + ) + except BaseException: + with self._pending_lock: + self._pending.pop(request_id, None) + raise + return request_id, pending + + def wait_response( + self, + request_id: int, + pending: _PendingRequest, + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: + try: + response = pending.response.get(timeout=timeout) + except queue.Empty as exc: + self.cancel(request_id, "Request timed out.") + self.abandon(request_id) + raise McpBridgeError(timeout_code, "MCP request timed out.", status_code=504) from exc + if isinstance(response, BaseException): + raise response + if "error" in response: + error = response.get("error") + message = ( + str(error.get("message", "MCP JSON-RPC error.")) + if isinstance(error, dict) + else "MCP JSON-RPC error." + ) + raise McpBridgeError(response_error_code, message) + result = response.get("result") + if not isinstance(result, dict): + raise McpBridgeError( + "MCP_TOOL_CALL_FAILED", "MCP response result must be an object." + ) + return result + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + self._send(payload) + + def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None: + try: + self.notify( + "notifications/cancelled", + {"requestId": request_id, "reason": reason}, + ) + except McpBridgeError: + pass + + def abandon(self, request_id: int) -> None: + with self._pending_lock: + self._pending.pop(request_id, None) + + def stop(self) -> None: + process = self.process + if process is None: + return + self._stopping = True + try: + if process.stdin: + try: + process.stdin.close() + except (BrokenPipeError, OSError, ValueError): + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + finally: + self._fail_pending( + McpBridgeError("PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503) + ) + self.process = None + + def _send(self, message: dict[str, Any]) -> None: + self._ensure_running() + encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.") + process = self.process + assert process is not None and process.stdin is not None + try: + with self._write_lock: + process.stdin.write(encoded + "\n") + process.stdin.flush() + except (BrokenPipeError, OSError, ValueError) as exc: + raise McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", "MCP host input is closed.", status_code=503 + ) from exc + + def _stdout_loop(self) -> None: + process = self.process + assert process is not None and process.stdout is not None + failure: str | None = None + try: + for raw_line in process.stdout: + if len(raw_line.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES: + failure = "MCP server emitted an oversized protocol message." + break + try: + message = json.loads(raw_line) + except json.JSONDecodeError: + failure = "MCP server emitted invalid JSON on stdout." + break + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + failure = "MCP server emitted an invalid JSON-RPC message." + break + self.on_seen() + if "id" in message and ("result" in message or "error" in message): + request_id = message.get("id") + if isinstance(request_id, int): + with self._pending_lock: + pending = self._pending.pop(request_id, None) + if pending: + pending.response.put(message) + continue + method = message.get("method") + if method == "notifications/tools/list_changed": + self.on_tools_changed() + elif isinstance(method, str) and "id" in message: + self._send( + { + "jsonrpc": "2.0", + "id": message["id"], + "error": {"code": -32601, "message": "Method not supported."}, + } + ) + except (McpBridgeError, OSError, ValueError) as exc: + failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}." + finally: + if failure and process.poll() is None: + process.terminate() + exit_code = process.poll() + if exit_code is None: + try: + exit_code = process.wait(timeout=1) + except subprocess.TimeoutExpired: + exit_code = None + if not self._stopping: + message = failure or f"MCP host exited unexpectedly with code {exit_code}." + error = McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", message, status_code=503 + ) + self._fail_pending(error) + self.on_broken(message) + + def _stderr_loop(self) -> None: + process = self.process + assert process is not None and process.stderr is not None + try: + for line in process.stderr: + self._stderr_tail.append(line.rstrip()[:1024]) + except (OSError, ValueError): + return + + def _ensure_running(self) -> None: + if self.process is None or self.process.poll() is not None: + raise McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", "MCP host is not running.", status_code=503 + ) + + def _fail_pending(self, error: BaseException) -> None: + with self._pending_lock: + pending = list(self._pending.values()) + self._pending.clear() + for item in pending: + item.response.put(error) + + +@dataclass(slots=True) +class _McpHost: + backend: PluginBackend + client: McpStdioClient + status: PluginHostStatus + + +class McpBridge: + """管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。""" + + def __init__(self) -> None: + self._hosts: dict[str, _McpHost] = {} + self._statuses: dict[str, PluginHostStatus] = {} + self._calls: dict[tuple[str, str], int] = {} + self._lock = threading.RLock() + + def start( + self, + plugin_id: str, + backend: PluginBackend, + package_path: Path, + declared_permissions: list[str], + on_unavailable: Callable[[str, str], None], + ) -> list[McpDiscoveredTool]: + if backend.transport != "stdio": + raise McpBridgeError( + "MCP_CAPABILITY_UNSUPPORTED", + "Phase C only supports the MCP stdio transport.", + status_code=501, + ) + command = self._resolve_command(package_path, backend) + now = datetime.now(timezone.utc) + status = PluginHostStatus( + plugin_id=plugin_id, + backend_type="mcp", + transport="stdio", + status=PluginHostState.starting, + started_at=now, + last_seen_at=now, + ) + host_ref: dict[str, _McpHost] = {} + + def seen() -> None: + host = host_ref.get("host") + if host: + host.status.last_seen_at = datetime.now(timezone.utc) + + def broken(message: str) -> None: + host = host_ref.get("host") + if host: + host.status.status = PluginHostState.unhealthy + host.status.error = message + on_unavailable(plugin_id, message) + + def tools_changed() -> None: + broken("MCP tool list changed; restart the Plugin Host to revalidate tools.") + + client = McpStdioClient( + command, + cwd=package_path, + on_seen=seen, + on_broken=broken, + on_tools_changed=tools_changed, + ) + host = _McpHost(backend=backend, client=client, status=status) + host_ref["host"] = host + with self._lock: + if plugin_id in self._hosts: + raise McpBridgeError( + "PLUGIN_HOST_START_FAILED", + f"MCP host is already running: {plugin_id}", + status_code=409, + ) + self._hosts[plugin_id] = host + self._statuses[plugin_id] = status + try: + client.start() + initialize = client.request( + "initialize", + { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "NotesAgent", "version": "0.1.0"}, + }, + timeout=backend.startup_timeout_seconds, + timeout_code="MCP_INITIALIZE_FAILED", + response_error_code="MCP_INITIALIZE_FAILED", + ) + version = initialize.get("protocolVersion") + if version not in SUPPORTED_PROTOCOL_VERSIONS: + raise McpBridgeError( + "MCP_INITIALIZE_FAILED", + f"Unsupported MCP protocol version: {version}", + ) + capabilities = initialize.get("capabilities") + if not isinstance(capabilities, dict) or not isinstance( + capabilities.get("tools"), dict + ): + raise McpBridgeError( + "MCP_CAPABILITY_UNSUPPORTED", + "MCP server does not declare the tools capability.", + ) + server_info = initialize.get("serverInfo") + if not isinstance(server_info, dict): + server_info = {} + status.protocol_version = str(version) + status.server_name = _optional_string(server_info.get("name")) + status.server_version = _optional_string(server_info.get("version")) + client.notify("notifications/initialized") + discovered = self._discover_tools( + plugin_id, client, backend, declared_permissions + ) + status.status = PluginHostState.ready + status.tools_count = len(discovered) + status.last_seen_at = datetime.now(timezone.utc) + status.error = None + return discovered + except McpBridgeError as exc: + status.status = PluginHostState.error + status.error = exc.message + client.stop() + with self._lock: + self._hosts.pop(plugin_id, None) + raise + except Exception as exc: + status.status = PluginHostState.error + status.error = f"MCP initialization failed: {type(exc).__name__}." + client.stop() + with self._lock: + self._hosts.pop(plugin_id, None) + raise McpBridgeError("MCP_INITIALIZE_FAILED", status.error) from exc + + async def call_tool( + self, + plugin_id: str, + remote_name: str, + arguments: dict[str, Any], + *, + request_id: str, + ) -> Any: + host = self._host(plugin_id) + rpc_id, pending = host.client.begin_request( + "tools/call", {"name": remote_name, "arguments": arguments} + ) + call_key = (plugin_id, request_id) + with self._lock: + self._calls[call_key] = rpc_id + try: + result = await asyncio.to_thread( + host.client.wait_response, + rpc_id, + pending, + timeout=host.backend.tool_timeout_seconds, + timeout_code="MCP_TOOL_CALL_FAILED", + ) + except asyncio.CancelledError: + host.client.cancel(rpc_id) + host.client.abandon(rpc_id) + raise + except McpBridgeError as exc: + raise ToolExecutionError(exc.code, exc.message) from exc + finally: + with self._lock: + self._calls.pop(call_key, None) + + encoded_size = len( + json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + if encoded_size > MAX_MCP_TOOL_RESULT_BYTES: + raise ToolExecutionError( + "MCP_TOOL_RESULT_TOO_LARGE", + "MCP tool result exceeds the configured size limit.", + ) + if result.get("isError") is True: + raise ToolExecutionError( + "MCP_TOOL_CALL_FAILED", _mcp_error_message(result.get("content")) + ) + structured = result.get("structuredContent") + if structured is not None: + if not isinstance(structured, dict): + raise ToolExecutionError( + "MCP_TOOL_CALL_FAILED", + "MCP structuredContent must be an object.", + ) + return structured + content = result.get("content", []) + if not isinstance(content, list): + raise ToolExecutionError( + "MCP_TOOL_CALL_FAILED", "MCP tool content must be an array." + ) + return {"content": content} + + def cancel(self, plugin_id: str, request_id: str) -> None: + with self._lock: + rpc_id = self._calls.get((plugin_id, request_id)) + host = self._hosts.get(plugin_id) + if rpc_id is not None and host is not None: + host.client.cancel(rpc_id) + + def stop(self, plugin_id: str) -> None: + with self._lock: + host = self._hosts.pop(plugin_id, None) + if host: + host.client.stop() + host.status.status = PluginHostState.stopped + host.status.tools_count = 0 + host.status.error = None + + def status(self, plugin_id: str, backend: PluginBackend) -> PluginHostStatus: + with self._lock: + status = self._statuses.get(plugin_id) + if status: + return status.model_copy(deep=True) + return PluginHostStatus( + plugin_id=plugin_id, + backend_type=backend.type, + transport=backend.transport, + status=PluginHostState.stopped, + ) + + def _discover_tools( + self, + plugin_id: str, + client: McpStdioClient, + backend: PluginBackend, + declared_permissions: list[str], + ) -> list[McpDiscoveredTool]: + discovered: list[McpDiscoveredTool] = [] + cursor: str | None = None + for _ in range(MAX_MCP_LIST_PAGES): + params = {"cursor": cursor} if cursor else {} + result = client.request( + "tools/list", + params, + timeout=backend.startup_timeout_seconds, + timeout_code="MCP_INITIALIZE_FAILED", + response_error_code="MCP_INITIALIZE_FAILED", + ) + raw_tools = result.get("tools") + if not isinstance(raw_tools, list): + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP tools/list must return a tools array." + ) + for raw in raw_tools: + discovered.append( + self._map_tool(plugin_id, raw, declared_permissions) + ) + if len(discovered) > MAX_MCP_TOOLS: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"MCP server exposes more than {MAX_MCP_TOOLS} tools.", + ) + next_cursor = result.get("nextCursor") + if next_cursor is None: + break + if not isinstance(next_cursor, str) or not next_cursor: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP nextCursor must be a non-empty string." + ) + cursor = next_cursor + else: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP tools/list exceeded the page limit." + ) + names = [item.definition.name for item in discovered] + if len(names) != len(set(names)): + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP server returned duplicate tool names." + ) + return discovered + + @staticmethod + def _map_tool( + plugin_id: str, raw: Any, declared_permissions: list[str] + ) -> McpDiscoveredTool: + if not isinstance(raw, dict): + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP tool definition must be an object." + ) + remote_name = raw.get("name") + if not isinstance(remote_name, str) or not remote_name: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", "MCP tool name must be a non-empty string." + ) + if ( + len(remote_name) > 128 + or not remote_name[0].isalnum() + or not all( + character.islower() + or character.isdigit() + or character in "._-" + for character in remote_name + ) + ): + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"MCP tool name is not a valid NotesAgent id: {remote_name}", + ) + schema = raw.get("inputSchema", {"type": "object", "properties": {}}) + if not isinstance(schema, dict) or schema.get("type", "object") != "object": + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"MCP tool inputSchema must be an object schema: {remote_name}", + ) + try: + Draft202012Validator.check_schema(schema) + except SchemaError as exc: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"Invalid MCP tool schema for {remote_name}: {exc.message}", + ) from exc + metadata = raw.get("_meta") + permission = ( + metadata.get("notesagent/permission") if isinstance(metadata, dict) else None + ) + if permission is not None and ( + not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS + ): + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"MCP tool declares an unknown permission: {remote_name}", + ) + if permission and permission not in declared_permissions: + raise McpBridgeError( + "MCP_TOOL_SCHEMA_INVALID", + f"MCP tool permission is missing from Plugin manifest: {permission}", + ) + description = raw.get("description") + return McpDiscoveredTool( + remote_name=remote_name, + definition=ToolDefinition( + name=f"{plugin_id}.{remote_name}", + description=description if isinstance(description, str) else remote_name, + parameters=schema, + permission=permission, + source="plugin", + ), + ) + + def _host(self, plugin_id: str) -> _McpHost: + with self._lock: + host = self._hosts.get(plugin_id) + if host is None or host.status.status != PluginHostState.ready: + raise ToolExecutionError( + "PLUGIN_HOST_UNAVAILABLE", f"MCP Plugin Host is not ready: {plugin_id}" + ) + return host + + @staticmethod + def _resolve_command(root: Path, backend: PluginBackend) -> list[str]: + if not backend.command or not backend.command.strip(): + raise McpBridgeError( + "PLUGIN_HOST_START_FAILED", "MCP stdio backend requires a command." + ) + command = backend.command.strip() + if Path(command).is_absolute() or "/" in command or "\\" in command: + executable = ( + (root / command).resolve() + if not Path(command).is_absolute() + else Path(command).resolve() + ) + try: + executable.relative_to(root) + except ValueError as exc: + raise McpBridgeError( + "PLUGIN_HOST_START_FAILED", + "MCP executable path must stay inside the Plugin package.", + ) from exc + command = str(executable) + return [command, *backend.args] + + +def _mcp_error_message(content: Any) -> str: + if isinstance(content, list): + texts = [ + item.get("text") + for item in content + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ] + if texts: + return "\n".join(texts)[:4096] + return "MCP tool returned an error result." + + +def _optional_string(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _subprocess_environment() -> dict[str, str]: + """只传递启动进程所需的系统变量,隔离 Provider Key、Vault 路径等宿主状态。""" + + allowed = { + "PATH", + "PATHEXT", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "TEMP", + "TMP", + "TMPDIR", + "LANG", + "LC_ALL", + "VIRTUAL_ENV", + } + environment = { + key: value for key, value in os.environ.items() if key.upper() in allowed + } + environment["PYTHONUNBUFFERED"] = "1" + environment["PYTHONIOENCODING"] = "utf-8" + return environment diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 2f701cb..33d0bbc 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import threading from dataclasses import dataclass from pathlib import Path from typing import Any, Literal @@ -16,6 +17,7 @@ from app.contracts import ( ModelCapability, Plugin, PluginManifest, + PluginHostStatus, PluginStatus, RetrievalConfig, Skill, @@ -23,6 +25,7 @@ from app.contracts import ( SkillStatus, ToolDefinition, ) +from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool _EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") @@ -242,18 +245,26 @@ class _PluginRecord: tools: list[DeclarativeToolSpec] package_path: Path registered_tools: list[str] + mcp_remote_names: dict[str, str] class PluginRuntime: """Plugin Manifest、生命周期及 Tool Contribution 注册。""" - def __init__(self, tools: ToolRegistry, host: DeclarativePluginHost | None = None) -> None: + def __init__( + self, + tools: ToolRegistry, + host: DeclarativePluginHost | None = None, + mcp_bridge: McpBridge | None = None, + ) -> None: self.registry = tools self.host = host or DeclarativePluginHost() + self.mcp = mcp_bridge or McpBridge() self._records: dict[str, _PluginRecord] = {} + self._lock = threading.RLock() def install(self, package_path: str | Path) -> Plugin: - # 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。 + # 安装阶段只读取清单;MCP 子进程必须在权限授予后的 enable 阶段启动。 root = _package_dir(package_path) raw = _read_yaml(root / "plugin.yaml") if "id" in raw and "plugin_id" not in raw: @@ -271,15 +282,17 @@ class PluginRuntime: status_code=409, ) - specs = self._load_tools(root) - declared = set(manifest.contributes.tools) - actual = {spec.name for spec in specs} - if declared != actual: - raise ExtensionError( - "PLUGIN_CONTRIBUTION_INVALID", - "plugin.yaml tool contributions must exactly match tools.yaml", - details={"declared": sorted(declared), "actual": sorted(actual)}, - ) + _validate_backend(manifest) + specs = [] if manifest.backend.type == "mcp" else self._load_tools(root) + if manifest.backend.type != "mcp": + declared = set(manifest.contributes.tools) + actual = {spec.name for spec in specs} + if declared != actual: + raise ExtensionError( + "PLUGIN_CONTRIBUTION_INVALID", + "plugin.yaml tool contributions must exactly match tools.yaml", + details={"declared": sorted(declared), "actual": sorted(actual)}, + ) for spec in specs: _validate_id("tool", spec.name) _validate_tool_schema(spec) @@ -302,6 +315,7 @@ class PluginRuntime: tools=specs, package_path=root, registered_tools=[], + mcp_remote_names={}, ) self._records[manifest.plugin_id] = record return record.plugin.model_copy(deep=True) @@ -313,18 +327,14 @@ class PluginRuntime: return self._record(plugin_id).plugin.model_copy(deep=True) def enable(self, plugin_id: str) -> Plugin: + # Host 启动和 Tool 批量注册必须串行,避免并发 enable 产生重复进程或半注册状态。 + with self._lock: + return self._enable(plugin_id) + + def _enable(self, plugin_id: str) -> Plugin: record = self._record(plugin_id) if record.plugin.enabled: return record.plugin.model_copy(deep=True) - if record.plugin.manifest.backend.type == "mcp": - # TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。 - record.plugin.status = PluginStatus.dependency_missing - raise ExtensionError( - "PLUGIN_HOST_UNAVAILABLE", - "MCP Plugin Host is reserved for the second development phase.", - status_code=501, - details={"plugin_id": plugin_id, "backend": "mcp"}, - ) missing_grants = sorted( set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions) ) @@ -336,7 +346,8 @@ class PluginRuntime: status_code=409, details={"plugin_id": plugin_id, "permissions": missing_grants}, ) - conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)] + declared_tools = list(record.plugin.manifest.contributes.tools) + conflicts = [name for name in declared_tools if self.registry.contains(name)] if conflicts: raise ExtensionError( "PLUGIN_TOOL_CONFLICT", @@ -346,42 +357,75 @@ class PluginRuntime: ) record.plugin.status = PluginStatus.starting try: - for spec in record.tools: - arguments_model = _arguments_model(spec) + if record.plugin.manifest.backend.type == "mcp": + discovered = self._start_mcp(record) + actual = {item.definition.name for item in discovered} + declared = set(declared_tools) + if actual != declared: + raise ExtensionError( + "PLUGIN_CONTRIBUTION_INVALID", + "Discovered MCP tools must exactly match Plugin contributions.", + details={"declared": sorted(declared), "actual": sorted(actual)}, + ) + for item in discovered: + self._register_mcp_tool(record, item) + else: + for spec in record.tools: + arguments_model = _arguments_model(spec) - async def executor( - arguments: BaseModel, - context: ToolExecutionContext, - _handler: str = spec.handler, - ) -> Any: - return await self.host.execute(_handler, arguments, context) + async def executor( + arguments: BaseModel, + context: ToolExecutionContext, + _handler: str = spec.handler, + ) -> Any: + return await self.host.execute(_handler, arguments, context) - self.registry.register( - ToolDefinition( - name=spec.name, - description=spec.description, - parameters=spec.parameters, - permission=spec.permission, - source="plugin", - ), - arguments_model, - executor, - ) - record.registered_tools.append(spec.name) + self.registry.register( + ToolDefinition( + name=spec.name, + description=spec.description, + parameters=spec.parameters, + permission=spec.permission, + source="plugin", + ), + arguments_model, + executor, + ) + record.registered_tools.append(spec.name) except Exception as exc: # 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。 for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + record.mcp_remote_names.clear() + self.mcp.stop(plugin_id) record.plugin.status = PluginStatus.error - record.plugin.error_message = str(exc) - raise + record.plugin.error_message = _safe_extension_message(exc) + if isinstance(exc, ExtensionError): + raise + if isinstance(exc, McpBridgeError): + raise ExtensionError( + exc.code, + exc.message, + status_code=exc.status_code, + details={"plugin_id": plugin_id}, + ) from exc + raise ExtensionError( + "PLUGIN_HOST_START_FAILED", + record.plugin.error_message, + status_code=503, + details={"plugin_id": plugin_id}, + ) from exc record.plugin.enabled = True record.plugin.status = PluginStatus.ready record.plugin.error_message = None return record.plugin.model_copy(deep=True) def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin: + with self._lock: + return self._set_permissions(plugin_id, permissions) + + def _set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin: record = self._record(plugin_id) requested = set(permissions) declared = set(record.plugin.manifest.permissions) @@ -403,15 +447,112 @@ class PluginRuntime: return record.plugin.model_copy(deep=True) def disable(self, plugin_id: str) -> Plugin: + with self._lock: + return self._disable(plugin_id) + + def _disable(self, plugin_id: str) -> Plugin: record = self._record(plugin_id) for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + record.mcp_remote_names.clear() + if record.plugin.manifest.backend.type == "mcp": + self.mcp.stop(plugin_id) record.plugin.enabled = False record.plugin.status = PluginStatus.disabled return record.plugin.model_copy(deep=True) + def get_host_status(self, plugin_id: str) -> PluginHostStatus: + record = self._record(plugin_id) + return self.mcp.status(plugin_id, record.plugin.manifest.backend) + + def restart_host(self, plugin_id: str) -> PluginHostStatus: + with self._lock: + return self._restart_host(plugin_id) + + def _restart_host(self, plugin_id: str) -> PluginHostStatus: + record = self._record(plugin_id) + if record.plugin.manifest.backend.type != "mcp": + raise ExtensionError( + "PLUGIN_HOST_UNAVAILABLE", + "Plugin does not use an MCP Host.", + status_code=409, + details={"plugin_id": plugin_id}, + ) + for name in record.registered_tools: + self.registry.unregister(name) + record.registered_tools.clear() + record.mcp_remote_names.clear() + self.mcp.stop(plugin_id) + record.plugin.enabled = False + record.plugin.status = PluginStatus.installed + record.plugin.error_message = None + self.enable(plugin_id) + return self.get_host_status(plugin_id) + + def shutdown(self) -> None: + """关闭所有隔离 Host;用于 FastAPI lifespan 和测试清理。""" + + with self._lock: + for plugin_id, record in list(self._records.items()): + if record.plugin.manifest.backend.type == "mcp": + self.mcp.stop(plugin_id) + + def _start_mcp(self, record: _PluginRecord) -> list[McpDiscoveredTool]: + manifest = record.plugin.manifest + return self.mcp.start( + manifest.plugin_id, + manifest.backend, + record.package_path, + manifest.permissions, + self._handle_mcp_unavailable, + ) + + def _register_mcp_tool( + self, record: _PluginRecord, discovered: McpDiscoveredTool + ) -> None: + definition = discovered.definition + arguments_model = _arguments_model_from_schema( + definition.name, definition.parameters + ) + plugin_id = record.plugin.manifest.plugin_id + remote_name = discovered.remote_name + + async def executor( + arguments: BaseModel, + context: ToolExecutionContext, + ) -> Any: + return await self.mcp.call_tool( + plugin_id, + remote_name, + arguments.model_dump(), + request_id=context.tool_call_id or f"{context.run_id}:{definition.name}", + ) + + self.registry.register(definition, arguments_model, executor) + record.registered_tools.append(definition.name) + record.mcp_remote_names[definition.name] = remote_name + + def _handle_mcp_unavailable(self, plugin_id: str, message: str) -> None: + with self._lock: + record = self._records.get(plugin_id) + if record is None: + return + for name in record.registered_tools: + self.registry.unregister(name) + record.registered_tools.clear() + record.mcp_remote_names.clear() + record.plugin.enabled = False + record.plugin.status = PluginStatus.error + record.plugin.error_message = message + def uninstall(self, plugin_id: str, dependent_skills: list[str] | None = None) -> None: + with self._lock: + self._uninstall(plugin_id, dependent_skills) + + def _uninstall( + self, plugin_id: str, dependent_skills: list[str] | None = None + ) -> None: record = self._record(plugin_id) if dependent_skills: raise ExtensionError( @@ -422,6 +563,8 @@ class PluginRuntime: ) if record.plugin.enabled: self.disable(plugin_id) + elif record.plugin.manifest.backend.type == "mcp": + self.mcp.stop(plugin_id) del self._records[plugin_id] def _record(self, plugin_id: str) -> _PluginRecord: @@ -498,6 +641,12 @@ def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError: def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]: schema = spec.parameters or {"type": "object", "properties": {}} + return _arguments_model_from_schema(spec.name, schema) + + +def _arguments_model_from_schema( + tool_name: str, schema: dict[str, Any] +) -> type[BaseModel]: if schema.get("type", "object") != "object": raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.") properties = schema.get("properties", {}) @@ -514,7 +663,7 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]: for name, field_schema in properties.items(): annotation = types.get(field_schema.get("type"), Any) fields[name] = (annotation, ... if name in required else None) - model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name) + model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name) return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields) @@ -536,3 +685,30 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: "Tool parameters must be an object schema with object properties.", details={"tool": spec.name}, ) + + +def _validate_backend(manifest: PluginManifest) -> None: + backend = manifest.backend + if backend.type == "mcp": + if backend.transport != "stdio": + raise ExtensionError( + "MCP_CAPABILITY_UNSUPPORTED", + "Phase C MCP Plugins must use stdio transport.", + status_code=501, + ) + if not backend.command or not backend.command.strip(): + raise ExtensionError( + "EXTENSION_MANIFEST_INVALID", + "MCP stdio backend requires a command.", + ) + elif backend.command is not None or backend.args: + raise ExtensionError( + "EXTENSION_MANIFEST_INVALID", + "Only MCP stdio backends may declare command or args.", + ) + + +def _safe_extension_message(exc: Exception) -> str: + if isinstance(exc, (ExtensionError, McpBridgeError)): + return exc.message + return f"Plugin Host operation failed: {type(exc).__name__}." diff --git a/backend/app/main.py b/backend/app/main.py index 510e019..8a3aae5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,19 +1,31 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHttpException from app.config import get_settings +from app.container import container from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler from app.routes import router as api_router from app.schemas import HealthResponse, ServiceStatusResponse settings = get_settings() + +@asynccontextmanager +async def lifespan(_: FastAPI): + yield + # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 + container.plugins.shutdown() + + app = FastAPI( title=settings.name, version=settings.version, description="AI 笔记软件的本地 AI Core 与 Agent Core 服务。", + lifespan=lifespan, ) app.add_middleware( diff --git a/backend/app/routes.py b/backend/app/routes.py index e5e2d07..201973e 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import AsyncIterator from datetime import datetime, timezone from uuid import uuid4 @@ -32,6 +33,7 @@ from app.contracts import ( PageMeta, PermissionDecisionRequest, Plugin, + PluginHostStatus, PluginListResponse, PluginPermissionGrantRequest, ProviderConfig, @@ -130,6 +132,15 @@ def extension_call(operation): raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc +async def extension_call_async(operation): + """进程启动/关闭可能等待 stdio Host,移出 FastAPI 事件循环。""" + + try: + return await asyncio.to_thread(operation) + except ExtensionError as exc: + raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc + + # Workspace (single configured Vault in Web development mode) @router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"]) async def get_workspace() -> WorkspaceInfo: @@ -483,7 +494,7 @@ async def install_plugin(request: ExtensionInstallRequest) -> Plugin: tags=["Plugins"], ) async def enable_plugin(plugin_id: str) -> Plugin: - return extension_call(lambda: container.plugins.enable(plugin_id)) + return await extension_call_async(lambda: container.plugins.enable(plugin_id)) @router.post( @@ -492,7 +503,7 @@ async def enable_plugin(plugin_id: str) -> Plugin: tags=["Plugins"], ) async def disable_plugin(plugin_id: str) -> Plugin: - return extension_call(lambda: container.plugins.disable(plugin_id)) + return await extension_call_async(lambda: container.plugins.disable(plugin_id)) @router.put( @@ -503,11 +514,37 @@ async def disable_plugin(plugin_id: str) -> Plugin: async def set_plugin_permissions( plugin_id: str, request: PluginPermissionGrantRequest ) -> Plugin: - return extension_call( + return await extension_call_async( lambda: container.plugins.set_permissions(plugin_id, request.permissions) ) +@router.get( + "/plugins/{plugin_id}/host", + response_model=PluginHostStatus, + tags=["Plugins"], +) +async def get_plugin_host_status(plugin_id: str) -> PluginHostStatus: + return extension_call(lambda: container.plugins.get_host_status(plugin_id)) + + +@router.post( + "/plugins/{plugin_id}/host/restart", + response_model=OperationResponse, + status_code=202, + tags=["Plugins"], +) +async def restart_plugin_host(plugin_id: str) -> OperationResponse: + status = await extension_call_async( + lambda: container.plugins.restart_host(plugin_id) + ) + return OperationResponse( + status="accepted", + resource_id=plugin_id, + message=f"Plugin Host status: {status.status.value}", + ) + + @router.delete( "/plugins/{plugin_id}", response_model=OperationResponse, @@ -516,7 +553,9 @@ async def set_plugin_permissions( async def uninstall_plugin(plugin_id: str) -> OperationResponse: plugin = extension_call(lambda: container.plugins.get(plugin_id)) dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools) - extension_call(lambda: container.plugins.uninstall(plugin_id, dependent_skills)) + await extension_call_async( + lambda: container.plugins.uninstall(plugin_id, dependent_skills) + ) return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled") diff --git a/backend/extensions/fixtures/mcp-echo/plugin.yaml b/backend/extensions/fixtures/mcp-echo/plugin.yaml new file mode 100644 index 0000000..34c4312 --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/plugin.yaml @@ -0,0 +1,21 @@ +id: mcp-fixture +name: MCP Fixture +version: 1.0.0 +description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。 +permissions: + - notes.read +contributes: + tools: + - mcp-fixture.echo + - mcp-fixture.fail + - mcp-fixture.sleep + - mcp-fixture.large + - mcp-fixture.environment + - mcp-fixture.exit +backend: + type: mcp + transport: stdio + command: python + args: [server.py] + startup_timeout_seconds: 5 + tool_timeout_seconds: 1 diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py new file mode 100644 index 0000000..6cf43e1 --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -0,0 +1,183 @@ +"""确定性的 MCP stdio 测试 Server;仅使用标准库,不依赖产品代码。""" + +from __future__ import annotations + +import json +import os +import sys +import threading +import time +from typing import Any + +WRITE_LOCK = threading.Lock() +CANCELLED: dict[int, threading.Event] = {} +MODE = sys.argv[1] if len(sys.argv) > 1 else "normal" + + +def send(message: dict[str, Any]) -> None: + with WRITE_LOCK: + sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def respond(request_id: int, result: dict[str, Any]) -> None: + send({"jsonrpc": "2.0", "id": request_id, "result": result}) + + +def tool(name: str, description: str, properties: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "name": name, + "description": description, + "inputSchema": { + "type": "object", + "properties": properties or {}, + "required": list(properties or {}), + "additionalProperties": False, + }, + } + + +TOOLS = { + "echo": { + **tool("echo", "Return the provided text.", {"text": {"type": "string"}}), + "_meta": {"notesagent/permission": "notes.read"}, + }, + "fail": tool("fail", "Return an MCP business error."), + "sleep": tool("sleep", "Wait until completed or cancelled.", {"seconds": {"type": "number"}}), + "large": tool("large", "Return a result larger than the host limit."), + "environment": tool("environment", "Report whether host secrets leaked into the process."), + "exit": tool("exit", "Terminate the fixture process."), +} + + +def call_tool(request_id: int, params: dict[str, Any]) -> None: + name = params.get("name") + arguments = params.get("arguments") or {} + if name == "echo": + text = str(arguments.get("text", "")) + respond( + request_id, + { + "content": [{"type": "text", "text": text}], + "structuredContent": {"echo": text}, + "isError": False, + }, + ) + return + if name == "fail": + respond( + request_id, + { + "content": [{"type": "text", "text": "fixture failure"}], + "isError": True, + }, + ) + return + if name == "large": + respond( + request_id, + { + "content": [{"type": "text", "text": "x" * 300_000}], + "isError": False, + }, + ) + return + if name == "environment": + respond( + request_id, + { + "content": [{"type": "text", "text": "environment checked"}], + "structuredContent": { + "has_openai_key": "OPENAI_API_KEY" in os.environ, + "has_app_db_path": "APP_DB_PATH" in os.environ, + }, + "isError": False, + }, + ) + return + if name == "exit": + os._exit(17) + if name == "sleep": + cancelled = CANCELLED.setdefault(request_id, threading.Event()) + seconds = max(0.0, min(float(arguments.get("seconds", 0)), 30.0)) + if cancelled.wait(seconds): + respond( + request_id, + { + "content": [{"type": "text", "text": "cancelled"}], + "isError": True, + }, + ) + else: + respond( + request_id, + { + "content": [{"type": "text", "text": "completed"}], + "structuredContent": {"slept": seconds}, + "isError": False, + }, + ) + CANCELLED.pop(request_id, None) + return + send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": f"Unknown tool: {name}"}, + } + ) + + +def main() -> None: + for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + request_id = message.get("id") + params = message.get("params") or {} + if method == "initialize" and isinstance(request_id, int): + respond( + request_id, + { + "protocolVersion": params.get("protocolVersion"), + "capabilities": ( + {} if MODE == "no-tools" else {"tools": {"listChanged": False}} + ), + "serverInfo": {"name": "notesagent-mcp-fixture", "version": "1.0.0"}, + }, + ) + elif method == "tools/list" and isinstance(request_id, int): + if MODE == "invalid-schema": + respond( + request_id, + { + "tools": [ + { + "name": "broken", + "description": "invalid schema", + "inputSchema": {"type": "string"}, + } + ] + }, + ) + elif params.get("cursor") == "page-2": + respond( + request_id, + {"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]}, + ) + else: + respond( + request_id, + {"tools": [TOOLS["echo"], TOOLS["fail"], TOOLS["sleep"]], "nextCursor": "page-2"}, + ) + elif method == "tools/call" and isinstance(request_id, int): + threading.Thread(target=call_tool, args=(request_id, params), daemon=True).start() + elif method == "notifications/cancelled": + cancelled_id = params.get("requestId") + if isinstance(cancelled_id, int): + CANCELLED.setdefault(cancelled_id, threading.Event()).set() + elif method == "ping" and isinstance(request_id, int): + respond(request_id, {}) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f62496e..e76a1e8 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -93,6 +93,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None: "/api/skills", "/api/plugins", "/api/plugins/install", + "/api/plugins/{plugin_id}/host", + "/api/plugins/{plugin_id}/host/restart", "/api/plugins/{plugin_id}/enable", "/api/plugins/{plugin_id}/disable", "/api/providers/test", diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 17f9891..1c3274a 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -1,4 +1,6 @@ import asyncio +import shutil +import time import pytest @@ -13,13 +15,28 @@ from app.contracts import ( ) from app.extensions import ExtensionError from app.services import note_service -from app.config import get_settings +from app.config import BACKEND_DIR, get_settings + + +MCP_FIXTURE = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo" def run(coroutine): return asyncio.run(coroutine) +@pytest.fixture +def mcp_container(): + container = build_container() + installed = container.plugins.install(MCP_FIXTURE) + assert installed.status == "permission_required" + container.plugins.set_permissions("mcp-fixture", ["notes.read"]) + try: + yield container + finally: + container.plugins.shutdown() + + def test_bundled_plugin_registers_tool_and_skill_is_ready() -> None: async def scenario() -> None: container = build_container() @@ -297,3 +314,196 @@ def test_attachment_and_transcription_tools_use_host_storage() -> None: assert transcription.output["text"] == "会议转写内容" run(scenario()) + + +def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( + mcp_container, monkeypatch +) -> None: + async def scenario() -> None: + monkeypatch.setenv("OPENAI_API_KEY", "must-not-enter-plugin-host") + enabled = mcp_container.plugins.enable("mcp-fixture") + status = mcp_container.plugins.get_host_status("mcp-fixture") + definition = mcp_container.tools.get("mcp-fixture.echo").definition + result = await mcp_container.tools.execute( + ToolCall( + tool_call_id="call_mcp_echo", + name="mcp-fixture.echo", + arguments={"text": "hello mcp"}, + ), + ToolExecutionContext( + run_id="run_mcp_fixture", tool_call_id="call_mcp_echo" + ), + ) + + assert enabled.status == "ready" and enabled.enabled is True + assert status.status == "ready" + environment = await mcp_container.tools.execute( + ToolCall( + tool_call_id="call_mcp_environment", + name="mcp-fixture.environment", + arguments={}, + ), + ToolExecutionContext(run_id="run_mcp_fixture"), + ) + + assert status.tools_count == 6 + assert status.protocol_version == "2025-11-25" + assert status.server_name == "notesagent-mcp-fixture" + assert definition.permission == "notes.read" + assert result.success is True + assert result.output == {"echo": "hello mcp"} + assert environment.success is True + assert environment.output == { + "has_openai_key": False, + "has_app_db_path": False, + } + + disabled = mcp_container.plugins.disable("mcp-fixture") + assert disabled.status == "disabled" + assert mcp_container.plugins.get_host_status("mcp-fixture").status == "stopped" + assert not mcp_container.tools.contains("mcp-fixture.echo") + + run(scenario()) + + +def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None: + async def scenario() -> None: + mcp_container.plugins.enable("mcp-fixture") + created = await mcp_container.agent.create_run( + AgentRunCreateRequest( + input='/tool mcp-fixture.echo {"text":"agent mcp"}', + provider_id="mock", + model="mock-1", + allowed_tools=["mcp-fixture.echo"], + ) + ) + completed = await mcp_container.agent.wait(created.run_id) + trace = mcp_container.agent.get_trace( + created.run_id, after_sequence=-1, limit=100 + ) + + assert completed.status == AgentRunStatus.completed + assert completed.tool_results[0].success is True + assert completed.tool_results[0].output == {"echo": "agent mcp"} + assert any( + item.event == "ToolCall" and item.data.get("name") == "mcp-fixture.echo" + for item in trace.items + ) + + run(scenario()) + + +def test_mcp_business_error_size_limit_and_timeout_are_structured(mcp_container) -> None: + async def scenario() -> None: + mcp_container.plugins.enable("mcp-fixture") + context = ToolExecutionContext(run_id="run_mcp_errors") + + failed = await mcp_container.tools.execute( + ToolCall(tool_call_id="call_fail", name="mcp-fixture.fail", arguments={}), + context, + ) + oversized = await mcp_container.tools.execute( + ToolCall(tool_call_id="call_large", name="mcp-fixture.large", arguments={}), + context, + ) + timed_out = await mcp_container.tools.execute( + ToolCall( + tool_call_id="call_sleep", + name="mcp-fixture.sleep", + arguments={"seconds": 5}, + ), + ToolExecutionContext( + run_id="run_mcp_errors", tool_call_id="call_sleep" + ), + ) + recovered = await mcp_container.tools.execute( + ToolCall( + tool_call_id="call_after_timeout", + name="mcp-fixture.echo", + arguments={"text": "still ready"}, + ), + context, + ) + + assert failed.success is False + assert failed.error_code == "MCP_TOOL_CALL_FAILED" + assert failed.error_message == "fixture failure" + assert oversized.success is False + assert oversized.error_code == "MCP_TOOL_RESULT_TOO_LARGE" + assert timed_out.success is False + assert timed_out.error_code == "MCP_TOOL_CALL_FAILED" + assert recovered.success is True + assert mcp_container.plugins.get_host_status("mcp-fixture").status == "ready" + + run(scenario()) + + +def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None: + async def scenario() -> None: + mcp_container.plugins.enable("mcp-fixture") + crashed = await mcp_container.tools.execute( + ToolCall(tool_call_id="call_exit", name="mcp-fixture.exit", arguments={}), + ToolExecutionContext(run_id="run_mcp_exit", tool_call_id="call_exit"), + ) + + deadline = time.monotonic() + 2 + while mcp_container.tools.contains("mcp-fixture.echo") and time.monotonic() < deadline: + await asyncio.sleep(0.02) + + plugin = mcp_container.plugins.get("mcp-fixture") + status = mcp_container.plugins.get_host_status("mcp-fixture") + assert crashed.success is False + assert crashed.error_code == "PLUGIN_HOST_UNAVAILABLE" + assert plugin.status == "error" and plugin.enabled is False + assert status.status == "unhealthy" + assert not mcp_container.tools.contains("mcp-fixture.echo") + + restarted = mcp_container.plugins.restart_host("mcp-fixture") + assert restarted.status == "ready" + assert restarted.tools_count == 6 + assert mcp_container.tools.contains("mcp-fixture.echo") + + run(scenario()) + + +@pytest.mark.parametrize( + ("mode", "contributions", "expected_code"), + [ + ("no-tools", "[]", "MCP_CAPABILITY_UNSUPPORTED"), + ("invalid-schema", "[mcp-invalid.broken]", "MCP_TOOL_SCHEMA_INVALID"), + ], +) +def test_mcp_rejects_missing_capability_and_invalid_discovery( + tmp_path, mode, contributions, expected_code +) -> None: + package = tmp_path / f"mcp-{mode}" + package.mkdir() + shutil.copyfile(MCP_FIXTURE / "server.py", package / "server.py") + (package / "plugin.yaml").write_text( + f""" +id: mcp-invalid +name: Invalid MCP Fixture +version: 1.0.0 +contributes: + tools: {contributions} +backend: + type: mcp + transport: stdio + command: python + args: [server.py, {mode}] + startup_timeout_seconds: 5 + tool_timeout_seconds: 1 +""".strip(), + encoding="utf-8", + ) + container = build_container() + container.plugins.install(package) + try: + with pytest.raises(ExtensionError) as exc: + container.plugins.enable("mcp-invalid") + assert exc.value.code == expected_code + assert container.plugins.get("mcp-invalid").status == "error" + assert container.plugins.get_host_status("mcp-invalid").status == "error" + assert not container.tools.contains("mcp-invalid.broken") + finally: + container.plugins.shutdown() diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 0fcfcfd..8891885 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -255,6 +255,22 @@ export type PluginStatus = | 'dependency_missing' | 'permission_required' +export type PluginHostState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error' + +export interface PluginHostStatus { + plugin_id: string + backend_type: 'mcp' | 'internal_rpc' | 'none' + transport: 'stdio' | 'http' | 'none' + status: PluginHostState + tools_count: number + started_at?: string | null + last_seen_at?: string | null + protocol_version?: string | null + server_name?: string | null + server_version?: string | null + error?: string | null +} + export interface PluginContribution { type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section' id: string @@ -539,7 +555,14 @@ export interface ApiPlugin { panels: string[] settings_sections: string[] } - backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' } + backend: { + type: 'mcp' | 'internal_rpc' | 'none' + transport: 'stdio' | 'http' | 'none' + command?: string | null + args?: string[] + startup_timeout_seconds?: number + tool_timeout_seconds?: number + } } status: PluginStatus enabled: boolean diff --git a/frontend/src/services/pluginService.ts b/frontend/src/services/pluginService.ts index 726a7e3..48b6a28 100644 --- a/frontend/src/services/pluginService.ts +++ b/frontend/src/services/pluginService.ts @@ -1,5 +1,5 @@ import apiClient from './apiClient' -import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts' +import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts' function toPlugin(plugin: ApiPlugin): Plugin { const { manifest } = plugin @@ -54,6 +54,14 @@ export async function grantPluginPermissions(pluginId: string, permissions: stri return toPlugin(await apiClient.put(`/api/plugins/${pluginId}/permissions`, { permissions })) } +export async function getPluginHostStatus(pluginId: string): Promise { + return apiClient.get(`/api/plugins/${pluginId}/host`) +} + +export async function restartPluginHost(pluginId: string): Promise { + return apiClient.post(`/api/plugins/${pluginId}/host/restart`) +} + export async function uninstallPlugin(pluginId: string): Promise { return apiClient.delete(`/api/plugins/${pluginId}`) } From aedb1c1267a102e57c999cf0ea17ff38b3a80963 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 11:32:47 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(extension):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E9=98=B6=E6=AE=B5C=20MCP=E5=BC=80=E5=8F=91=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- backend/README.md | 2 +- docs/README.md | 1 + .../AI笔记软件技术栈说明-团队版-v2.3.md | 16 +- docs/architecture/第二阶段团队分工表.md | 4 +- docs/contracts/后端接口契约-开发版.md | 7 +- docs/contracts/第二阶段接口契约-开发版.md | 32 ++- .../AI-Core与Agent-Core开发说明.md | 19 +- .../Knowledge与Retrieval-Core开发说明.md | 2 +- .../MCP-Bridge与Plugin-Host开发说明.md | 262 ++++++++++++++++++ docs/development/前端壳子与接口层开发说明.md | 4 +- .../模型提供商与模型发现开发说明.md | 2 +- .../后端全面审阅问题与修复复盘.md | 2 +- 13 files changed, 328 insertions(+), 28 deletions(-) create mode 100644 docs/development/MCP-Bridge与Plugin-Host开发说明.md diff --git a/README.md b/README.md index 7bfa242..bfc698e 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 87 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 @@ -132,6 +132,7 @@ pnpm test | [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 | | [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 | | [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core | +| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 | | [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 | | [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 | | [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 | diff --git a/backend/README.md b/backend/README.md index c211fc1..3c8429e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 87 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/docs/README.md b/docs/README.md index 5857184..00e38a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ - [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md) - [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md) - [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md) +- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md) - [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md) - [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md) - [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md) diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index a94e5a1..622abdc 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -5,7 +5,7 @@ > 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步 > 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员 -> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault,第二阶段 Agent Trace 持久化、分页快照和可恢复 SSE 已完成。后续继续接入真实音频处理、MCP、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 +> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已完成。后续继续接入真实音频处理、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 --- @@ -1044,6 +1044,8 @@ Plugin Host 负责: MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。 +当前已实现本地 stdio 首版:Plugin Runtime 在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list`、`tools/call`、取消、超时、异常退出和 Host Restart。实现接受 `2025-11-25`、`2025-06-18`、`2025-03-26` 与 `2024-11-05` 协议版本;Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。 + MCP Tool 进入系统后的调用路径为: ```text @@ -1062,7 +1064,7 @@ Tool Registry 仍使用项目自己的 `ToolDefinition` 和 `ToolResult`。MCP B MCP 能力首先用于 Tool 和 Resource 类扩展。需要复杂 UI 的插件通过 Frontend Extension Slot 单独处理。 -第二阶段 MCP Bridge 至少覆盖以下协议边界: +当前 stdio MCP Bridge 已覆盖以下协议边界: ```text Server Process / Connection Lifecycle @@ -1074,7 +1076,7 @@ tools/call 与 ToolResult 映射 健康检查与 Tool 注销 ``` -首个宿主实现优先支持本地 `stdio` 传输;其他传输在兼容性测试后增加。外部 Server 的 Tool 名称进入项目注册表前添加 Plugin 命名空间,并校验 JSON Schema、权限和重复 ID。MCP 内容不得绕过项目自己的 Permission、超时、日志净化和结果大小限制。 +首个宿主实现支持本地 `stdio` 传输;其他传输在兼容性测试后增加。外部 Server 的 Tool 名称进入项目注册表前添加 Plugin 命名空间,并校验 JSON Schema、权限、重复 ID 及其与 Manifest Contribution 的一致性。MCP 调用复用项目自己的 Permission、超时、Agent Trace、日志净化和结果大小限制;子进程环境按白名单裁剪,不传入 Provider Key、Vault 或数据库路径。 ### 12.6 Frontend Extension Slot @@ -2321,7 +2323,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成,第二阶段前置的 Workspace 去 Mock 联调及 Agent Trace 持久化/恢复接口也已完成。当前验证基线为后端 81 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 87 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: @@ -2331,7 +2333,7 @@ Multimodal └── pyannote.audio Extension / Model -├── MCP Bridge +├── MCP Bridge(stdio 首版已实现) ├── Plugin Command Contribution ├── Plugin Settings Contribution └── Provider Streaming / Tool Calling / Error Mapping 增强 @@ -2353,7 +2355,7 @@ Frontend Extension └── Plugin Settings UI ``` -上述列表描述第二阶段技术范围,不表示能力已经实现。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。 +上述列表描述第二阶段技术范围,其中 stdio MCP Bridge 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。 第三阶段处理: @@ -2409,7 +2411,7 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启 Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 在第二阶段接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并将扩展 Agent Trace Contract 供可视化和 Benchmark 共用;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。 -当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution;第二阶段通过 MCP Bridge 接入隔离 Tool,并增加 Command 与 Settings Contribution。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,第二阶段按统一行为测试完善 OpenAI Responses、Anthropic Messages 等协议。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。 +当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution,并已通过 stdio MCP Bridge 接入独立进程 Tool、Host 状态与重启接口;Command 与 Settings Contribution 尚待后续阶段实现。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,第二阶段按统一行为测试完善 OpenAI Responses、Anthropic Messages 等协议。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。 第二阶段内容输出以 Document AST、Exporter Adapter、Mermaid Renderer 和 Function Plot Renderer 为共同边界,支持 HTML、PDF、DOCX 与静态图导出。Theme Package 使用 Manifest、Design Token 和受限 CSS 实现本地导入;联网主题市场不属于本阶段核心依赖。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。 diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md index 6157b55..7fafabf 100644 --- a/docs/architecture/第二阶段团队分工表.md +++ b/docs/architecture/第二阶段团队分工表.md @@ -746,8 +746,8 @@ Markdown - [ ] faster-whisper 能完成真实音频转写; - [ ] pyannote.audio 能生成说话人分段; - [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript; -- [ ] MCP Server 能通过 MCP Bridge 注册 Tool; -- [ ] Agent 能调用 MCP Tool; +- [x] MCP Server 能通过 MCP Bridge 注册 Tool; +- [x] Agent 能调用 MCP Tool; - [ ] Plugin Command Contribution 后端可注册; - [ ] Plugin Settings Contribution 后端可解析; - [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定; diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index bc44e48..e639636 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -1,6 +1,6 @@ # 后端接口契约(开发版) -> 更新日期:2026-08-31。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。 +> 更新日期:2026-09-01。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。 ## 契约入口 @@ -76,6 +76,8 @@ Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault, | POST | `/api/plugins/{plugin_id}/enable` | 启用 Plugin | | POST | `/api/plugins/{plugin_id}/disable` | 停用 Plugin | | PUT | `/api/plugins/{plugin_id}/permissions` | 设置 Plugin 已授权权限 | +| GET | `/api/plugins/{plugin_id}/host` | 获取隔离 MCP Host 状态、工具数和协商信息 | +| POST | `/api/plugins/{plugin_id}/host/restart` | 重启 MCP Host 并重新发现、校验和注册 Tool | | DELETE | `/api/plugins/{plugin_id}` | 卸载 Plugin | ### Provider @@ -174,7 +176,7 @@ RunCancelled ## 当前实现状态 -更新至 2026-09-01:后端 81 项回归测试通过。 +更新至 2026-09-01:后端 87 项回归测试通过。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 @@ -183,6 +185,7 @@ RunCancelled - Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。 - Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。 - Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。 +- 本地 stdio MCP Server 已通过独立子进程接入 Plugin Runtime;Agent 只消费内部 Tool Contract。Host 支持 initialize、分页发现、调用、超时取消、状态查询、重启和异常退出后的 Tool 注销。 - Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。 - 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 58f3da2..7428e42 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -2,7 +2,7 @@ > 文档状态:接口冻结草案 > -> 更新日期:2026-08-31 +> 更新日期:2026-09-01 > > 依据:`../architecture/第二阶段团队分工表.md`、`../architecture/AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md` @@ -45,8 +45,8 @@ | Transcription | POST | `/api/media/transcriptions/{job_id}/notes` | 计划新增 | 将 Transcript 写入 Knowledge Core | | Agent Trace | GET | `/api/agent/runs/{run_id}/events` | 已实现 | 支持游标恢复并增加模型与权限事件 | | 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 | +| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 | +| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool | | 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 与非敏感配置 | @@ -427,7 +427,29 @@ class McpBridge(Protocol): async def stop(self, plugin_id: str) -> None: ... ``` -首个实现支持本地 `stdio`。Host 负责 initialize、capability negotiation、进程生命周期、超时、取消、stderr 隔离和异常退出后的 Tool 注销。 +首个实现已支持本地 `stdio`,按 MCP `2025-11-25` 发起 initialize,并兼容 `2025-06-18`、`2025-03-26` 和 `2024-11-05` 协商结果。Host 负责 capability negotiation、分页 `tools/list`、进程生命周期、超时取消、stderr 隔离和异常退出后的 Tool 注销。stdio 消息使用 UTF-8 单行 JSON-RPC;当前不实现 Streamable HTTP。 + +MCP Plugin 的 `backend` 增加: + +```yaml +backend: + type: mcp + transport: stdio + command: python + args: [server.py] + startup_timeout_seconds: 5 + tool_timeout_seconds: 30 +``` + +命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY`、`APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 + +远端 Tool 的可选项目权限放在 MCP `_meta`: + +```json +{ "_meta": { "notesagent/permission": "notes.read" } } +``` + +该权限必须属于项目已知权限并同时出现在 Plugin Manifest 中。发现结果必须与 `contributes.tools` 的命名空间 ID 完全一致;校验全部成功后才一次性发布到 Tool Registry。 当前 Web 开发接口 `POST /api/plugins/install` 使用 `package_path`。桌面 Host 接入后,`ExtensionInstallRequest` 增加 `package_id`,由文件选择器产生临时包句柄;`package_path` 只在明确的 development 环境保留并标记 deprecated,生产构建拒绝任意前端路径。 @@ -460,6 +482,8 @@ error `POST /api/plugins/{plugin_id}/host/restart` 返回 `202 OperationResponse`。重启期间先注销旧 Tool,发现和校验全部成功后再一次性发布新 Tool 集合,避免半注册状态。 +当前实现还返回协商后的 `protocol_version`、`server_name` 和 `server_version`。单条协议消息上限为 2 MiB,单次 Tool Result 上限为 256 KiB;超限分别按 Host/Result 错误处理。Server 异常退出或发送无效 stdout 消息时,Host 进入 `unhealthy`,Plugin 进入 `error`,相关 Tool 立即注销。 + ### 7.3 Command Contribution 列表 `GET /api/plugin-contributions/commands?location=command_palette` diff --git a/docs/development/AI-Core与Agent-Core开发说明.md b/docs/development/AI-Core与Agent-Core开发说明.md index a3b9731..e3cf687 100644 --- a/docs/development/AI-Core与Agent-Core开发说明.md +++ b/docs/development/AI-Core与Agent-Core开发说明.md @@ -2,7 +2,7 @@ > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 -> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化和可恢复 SSE 已落地,后端当前回归基线为 81 项测试通过。 +> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 87 项测试通过。 ## 当前实现 @@ -33,12 +33,14 @@ backend/app/ │ ├── permissions.py 权限策略、确认请求和会话授权 │ └── builtin_tools.py 无副作用的内置开发 Tool ├── extensions/ -│ └── runtime.py Skill/Plugin Manifest、生命周期、依赖与 Tool Contribution +│ ├── runtime.py Skill/Plugin Manifest、生命周期、依赖与 Tool Contribution +│ └── mcp.py stdio JSON-RPC、MCP 生命周期、发现、调用与 Host 隔离 └── container.py AI Core 依赖组装 backend/extensions/ ├── skills/knowledge-assistant/ 内置知识库 Skill -└── plugins/text-tools/ 内置示例 Plugin +├── plugins/text-tools/ 内置声明式 Plugin +└── fixtures/mcp-echo/ 离线 MCP Server 联调 Fixture ``` Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider 业务逻辑。 @@ -57,6 +59,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider - SQLite Trace、分页快照与可恢复 SSE; - Skill Manifest、Prompt、Tool/Permission/模型能力解析; - Plugin Manifest、生命周期和 Tool Contribution; +- stdio MCP Bridge、隔离进程生命周期、Tool 映射与 Host 健康状态; - Skill 调用内置 Tool 与 Plugin Tool; - 公共 Contract 和 API 接入。 @@ -65,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider - Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供; - FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供; - 文件系统和 API Key 明文读取:由 Rust Host 提供; -- MCP Plugin Host、Frontend Extension Slot:按技术基线放在第二阶段实现。 +- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。 ## Provider @@ -302,7 +305,7 @@ DELETE /api/skills/{skill_id} ### Plugin Runtime -第一阶段 Plugin Runtime 完成 Manifest 校验、安装、启用、停用、卸载和 Tool Contribution。第三方代码不会直接 import 到 AI Core;当前 Declarative Plugin Host 只执行宿主实现的白名单 handler,MCP Host 留到第二阶段。 +第一阶段 Plugin Runtime 完成 Manifest 校验、安装、启用、停用、卸载和声明式 Tool Contribution。阶段 C 增加 stdio MCP Bridge:第三方代码不会直接 import 到 AI Core,而由独立子进程运行,通过换行分隔 JSON-RPC 完成 initialize、Tool 发现和调用。 启用 Plugin 时将 Tool 注册到统一 Tool Registry,并标记 `source=plugin`;停用或异常时注销 Tool。启用中的 Skill 依赖某 Plugin Tool 时,Plugin 不能直接卸载。 @@ -315,11 +318,15 @@ GET /api/plugins/{plugin_id} POST /api/plugins/{plugin_id}/enable POST /api/plugins/{plugin_id}/disable PUT /api/plugins/{plugin_id}/permissions +GET /api/plugins/{plugin_id}/host +POST /api/plugins/{plugin_id}/host/restart DELETE /api/plugins/{plugin_id} ``` Plugin Manifest 中的权限只是声明,不代表已经授权。带权限的 Plugin 安装后进入 `permission_required`,Host 必须通过权限接口记录用户授权,之后才能启用。JSON Schema 在安装阶段校验,Tool 调用时再次校验实际参数。 +MCP Tool 进入 Registry 前统一增加 `.` 命名空间。Server 声明的 `notesagent/permission` 必须属于已知权限并出现在 Plugin Manifest;发现集合还必须与 Manifest Contribution 完全一致。启用失败会回滚全部 Tool 并关闭子进程,异常退出会把 Plugin 标记为 `error` 并立即注销对应 Tool。详细实现和 Fixture 操作见 [MCP Bridge 与 Plugin Host 开发说明](MCP-Bridge与Plugin-Host开发说明.md)。 + 内置示例 `text-tools` 注册 `text.uppercase`。内置 `knowledge-assistant` Skill 同时声明 `notes.search` 和 `text.uppercase`,用于验证完整链路: ```text @@ -342,4 +349,4 @@ Skill Manifest - Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。 - `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。 - Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。 -- 当前 Plugin Host 只支持内置声明式白名单 handler;MCP Bridge、独立进程健康检查与 UI Contribution 在第二阶段实现。 +- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP Server;Streamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。 diff --git a/docs/development/Knowledge与Retrieval-Core开发说明.md b/docs/development/Knowledge与Retrieval-Core开发说明.md index c109a9e..2fcd38c 100644 --- a/docs/development/Knowledge与Retrieval-Core开发说明.md +++ b/docs/development/Knowledge与Retrieval-Core开发说明.md @@ -3,7 +3,7 @@ > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 -> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 81 项测试通过。 +> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 87 项测试通过。 ## 当前实现 diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md new file mode 100644 index 0000000..61bb74b --- /dev/null +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -0,0 +1,262 @@ +# MCP Bridge 与 Plugin Host 开发说明 + +> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。 + +## 1. 目标与实现状态 + +阶段 C 的目标是让外部 MCP Server 进入既有 Plugin、Tool、Permission、Agent 和 Trace 链路,同时避免 Agent Runtime、前端或 Benchmark 直接依赖 MCP 原始消息。 + +当前链路: + +```text +Plugin Manifest +→ Plugin Runtime +→ 独立 stdio MCP Server 进程 +→ initialize / capability negotiation +→ tools/list 分页发现与校验 +→ NotesAgent ToolDefinition +→ Tool Registry / Permission Manager +→ Agent Runtime / Agent Trace +``` + +已经实现: + +- 本地 stdio 子进程启动、关闭和异常退出检测; +- UTF-8、换行分隔的 JSON-RPC 2.0 消息; +- initialize、协议版本与 tools capability 协商; +- `notifications/initialized`; +- 分页 `tools/list`; +- `tools/call`、业务错误与 JSON-RPC 错误转换; +- 超时和 `notifications/cancelled`; +- Tool 命名空间、JSON Schema、权限和 Manifest 集合校验; +- Host 状态查询、重启和异常后的 Tool 自动注销; +- stderr 隔离、环境变量裁剪、消息及结果大小限制; +- 无网络、无密钥的确定性 MCP Fixture。 + +实现依据为 MCP 官方 [Lifecycle 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)、[Transports 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 和 [Tools 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。 + +## 2. 代码位置 + +```text +backend/app/extensions/mcp.py + stdio 进程、JSON-RPC、MCP 生命周期、发现、调用和 Host 状态 + +backend/app/extensions/runtime.py + Plugin Manifest、权限、MCP Tool 批量注册/回滚和生命周期集成 + +backend/app/agent/tools.py + 内部 Tool 参数校验、结构化执行错误和线程安全 Registry + +backend/extensions/fixtures/mcp-echo/ + 确定性 stdio MCP Server 与 Plugin Manifest +``` + +## 3. Plugin Manifest + +MCP Plugin 的后端配置示例: + +```yaml +id: example-mcp +name: Example MCP +version: 1.0.0 +permissions: + - notes.read +contributes: + tools: + - example-mcp.search +backend: + type: mcp + transport: stdio + command: python + args: [server.py] + startup_timeout_seconds: 5 + tool_timeout_seconds: 30 +``` + +约束: + +- 阶段 C 只接受 `type: mcp` 与 `transport: stdio`; +- 命令和参数通过数组直接传给 `subprocess.Popen`,不经过 Shell; +- PATH 中的 executable 使用名称,例如 `python`、`node`; +- manifest 中带目录的 executable 必须解析到 Plugin 包内部; +- `contributes.tools` 使用 `.`; +- 安装阶段只读 Manifest,不启动第三方进程; +- 完成用户授权后,`enable` 才启动 Host。 + +## 4. 生命周期 + +### 4.1 启动 + +启用 MCP Plugin 时依次执行: + +1. 检查 Plugin 声明权限是否全部获得授权; +2. 检查 Manifest 声明的 Tool ID 是否与现有 Registry 冲突; +3. 启动独立 stdio Server; +4. 发送 `initialize`; +5. 校验协商版本和 `tools` capability; +6. 发送 `notifications/initialized`; +7. 分页读取 `tools/list`; +8. 校验全部 Tool; +9. 确认发现集合与 Manifest 完全一致; +10. 将完整集合注册到 Tool Registry; +11. Plugin 和 Host 进入 `ready`。 + +任何步骤失败都会注销本轮已注册 Tool、关闭子进程并把 Plugin 标记为 `error`,不会留下半启用状态。 + +### 4.2 停止与异常退出 + +停用、卸载或应用关闭时,先注销 Tool,再关闭 stdin,等待 Server 正常退出。超时后依次 terminate 和 kill。 + +Server 异常退出、stdout 出现非 JSON-RPC 内容或发送超大协议消息时: + +- 未完成请求返回 `PLUGIN_HOST_UNAVAILABLE`; +- Host 进入 `unhealthy`; +- Plugin 进入 `error`; +- 对应 Tool 从 Registry 中立即注销; +- 用户可以调用 Host Restart 接口重新协商和发现。 + +Server 发送 `notifications/tools/list_changed` 时不会直接信任新集合。当前实现先把 Host 标记为不健康并注销旧 Tool,要求通过 Restart 重新执行完整发现与校验。 + +## 5. Tool Contract 转换 + +MCP Tool: + +```json +{ + "name": "search", + "description": "Search notes", + "inputSchema": { "type": "object", "properties": {} }, + "_meta": { "notesagent/permission": "notes.read" } +} +``` + +进入系统后转换为: + +```json +{ + "name": "example-mcp.search", + "description": "Search notes", + "parameters": { "type": "object", "properties": {} }, + "permission": "notes.read", + "source": "plugin" +} +``` + +转换规则: + +- 远端名称必须能转换为合法且稳定的项目 Tool ID; +- `inputSchema` 必须是有效的 object JSON Schema; +- `_meta.notesagent/permission` 必须属于项目已知权限; +- Tool 权限必须同时出现在 Plugin Manifest 中; +- Agent 仍通过 Tool Registry 执行参数校验、Permission、超时和 Trace; +- MCP `structuredContent` 存在时映射为内部 output;否则保留为受控 `content` 数组; +- MCP `isError: true` 映射为 `MCP_TOOL_CALL_FAILED`; +- 结果超过 256 KiB 映射为 `MCP_TOOL_RESULT_TOO_LARGE`。 + +## 6. 隔离与安全边界 + +当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。 + +已经执行的保护: + +- 第三方模块不 import 到 AI Core; +- 子进程 `cwd` 固定为 Plugin 包目录; +- 不使用 Shell 拼接命令; +- 不把 Provider API Key、`APP_DB_PATH`、Vault 路径和其他宿主环境变量传入子进程; +- stderr 与 JSON-RPC stdout 分离,stderr 不进入 API 和 Agent Trace; +- stdout 只能发送合法 MCP JSON-RPC; +- 单条协议消息上限 2 MiB; +- 单次 Tool Result 上限 256 KiB; +- MCP Tool 不绕过 Permission Manager 和 Agent Tool Timeout。 + +当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发前必须继续增加包签名、来源验证和平台级沙箱;不得把当前进程隔离描述为完全安全执行任意不可信代码。 + +## 7. Host API + +```http +GET /api/plugins/{plugin_id}/host +POST /api/plugins/{plugin_id}/host/restart +``` + +状态响应包含: + +```text +plugin_id +backend_type / transport +status +tools_count +started_at / last_seen_at +protocol_version +server_name / server_version +error +``` + +状态值: + +```text +stopped +starting +ready +unhealthy +error +``` + +Restart 返回 `202 OperationResponse`。接口返回前已完成本地 Host 重启和 Tool 重新发现;`message` 中给出最终 Host 状态。 + +## 8. 离线 Fixture + +Fixture 位于: + +```text +backend/extensions/fixtures/mcp-echo +``` + +它提供: + +- `mcp-fixture.echo`:返回 structuredContent; +- `mcp-fixture.fail`:返回 `isError: true`; +- `mcp-fixture.sleep`:验证超时和取消; +- `mcp-fixture.large`:验证结果大小上限; +- `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程; +- `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。 + +Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability 和返回无效 Schema 的变体。 + +## 9. 验证 + +```powershell +cd backend +uv run python -m compileall -q app +uv run pytest + +cd ../frontend +pnpm test +pnpm type-check +pnpm build +``` + +阶段 C 新增测试覆盖: + +- initialize、版本和 capability negotiation; +- 分页 `tools/list` 与命名空间映射; +- Permission、JSON Schema 与 Contribution 集合; +- Tool 成功、业务错误、结果过大和超时; +- Agent Runtime 调用 MCP Tool 并写入正式 Trace; +- Secret/Vault 环境隔离; +- Server 异常退出、Tool 注销和 Host Restart; +- 缺少 capability 与无效 MCP Schema; +- OpenAPI 发布 Host 状态和重启路径。 + +## 10. 当前边界与后续阶段 + +阶段 C 不包含: + +- Streamable HTTP MCP transport; +- Resources、Prompts、Sampling、Elicitation 和 MCP Tasks; +- Plugin Command 与 Settings Contribution; +- Secret Reference 注入; +- Plugin Registry 持久化、签名与社区来源校验; +- 操作系统级沙箱; +- Tool 列表热更新的无中断替换。 + +阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。 diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index 83a76c6..de7fc1a 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -187,12 +187,12 @@ pnpm build ```text pnpm build passed pnpm test 27 passed -uv run pytest 81 passed +uv run pytest 87 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 81 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 87 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/development/模型提供商与模型发现开发说明.md b/docs/development/模型提供商与模型发现开发说明.md index 5a6fa20..0d72d9a 100644 --- a/docs/development/模型提供商与模型发现开发说明.md +++ b/docs/development/模型提供商与模型发现开发说明.md @@ -104,4 +104,4 @@ pnpm build 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 -当前完整回归基线:后端 81 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 +当前完整回归基线:后端 87 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 diff --git a/docs/retrospectives/后端全面审阅问题与修复复盘.md b/docs/retrospectives/后端全面审阅问题与修复复盘.md index fc08ea6..401fa16 100644 --- a/docs/retrospectives/后端全面审阅问题与修复复盘.md +++ b/docs/retrospectives/后端全面审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 -> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储和 Agent Trace 持久化,当前完整后端回归基线为 81 项测试通过。 +> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 87 项测试通过。 ## 1. 审阅结论 From 1132a4cece83bbd5acb83d36d493ba9767ed6811 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 12:11:30 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(extension):=20=E4=BF=AE=E5=A4=8D=20MCP?= =?UTF-8?q?=20Host=20=E8=B5=84=E6=BA=90=E4=B8=8E=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/README.md | 2 +- backend/app/extensions/mcp.py | 39 ++++++++++-- backend/app/extensions/runtime.py | 15 ++++- .../extensions/fixtures/mcp-echo/server.py | 9 +++ backend/tests/test_extension_core.py | 62 ++++++++++++++++++- .../AI笔记软件技术栈说明-团队版-v2.3.md | 2 +- docs/contracts/后端接口契约-开发版.md | 2 +- docs/contracts/第二阶段接口契约-开发版.md | 4 +- .../AI-Core与Agent-Core开发说明.md | 2 +- .../Knowledge与Retrieval-Core开发说明.md | 2 +- .../MCP-Bridge与Plugin-Host开发说明.md | 13 ++-- docs/development/前端壳子与接口层开发说明.md | 4 +- .../模型提供商与模型发现开发说明.md | 2 +- .../后端全面审阅问题与修复复盘.md | 2 +- 15 files changed, 138 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bfc698e..ac7a7db 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 87 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 91 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/README.md b/backend/README.md index 3c8429e..6a0930e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 87 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 91 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py index 2647c20..d5036a7 100644 --- a/backend/app/extensions/mcp.py +++ b/backend/app/extensions/mcp.py @@ -188,7 +188,7 @@ class McpStdioClient: result = response.get("result") if not isinstance(result, dict): raise McpBridgeError( - "MCP_TOOL_CALL_FAILED", "MCP response result must be an object." + response_error_code, "MCP response result must be an object." ) return result @@ -207,9 +207,18 @@ class McpStdioClient: except McpBridgeError: pass - def abandon(self, request_id: int) -> None: + def abandon( + self, request_id: int, wake_error: BaseException | None = None + ) -> None: with self._pending_lock: - self._pending.pop(request_id, None) + pending = self._pending.pop(request_id, None) + # asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程 + # 一直占用默认线程池直至远端超时。 + if pending is not None and wake_error is not None: + try: + pending.response.put_nowait(wake_error) + except queue.Full: + pass def stop(self) -> None: process = self.process @@ -258,7 +267,15 @@ class McpStdioClient: assert process is not None and process.stdout is not None failure: str | None = None try: - for raw_line in process.stdout: + while True: + # readline(size) 在换行缺失时仍有硬上限,不能先把任意大的 + # 第三方 stdout 行完整读入宿主内存再检查。 + raw_line = process.stdout.readline(MAX_MCP_MESSAGE_BYTES + 1) + if raw_line == "": + break + if not raw_line.endswith("\n"): + failure = "MCP server emitted an oversized or unterminated message." + break if len(raw_line.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES: failure = "MCP server emitted an oversized protocol message." break @@ -313,7 +330,12 @@ class McpStdioClient: process = self.process assert process is not None and process.stderr is not None try: - for line in process.stderr: + while True: + # stderr 不是协议通道,但同样按块读取,避免无换行日志造成 + # 宿主侧的无界字符串分配。 + line = process.stderr.readline(1025) + if line == "": + break self._stderr_tail.append(line.rstrip()[:1024]) except (OSError, ValueError): return @@ -489,7 +511,12 @@ class McpBridge: ) except asyncio.CancelledError: host.client.cancel(rpc_id) - host.client.abandon(rpc_id) + host.client.abandon( + rpc_id, + McpBridgeError( + "MCP_TOOL_CALL_FAILED", "MCP request was cancelled." + ), + ) raise except McpBridgeError as exc: raise ToolExecutionError(exc.code, exc.message) from exc diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 33d0bbc..fa6f72f 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -479,6 +479,17 @@ class PluginRuntime: status_code=409, details={"plugin_id": plugin_id}, ) + if record.plugin.status in { + PluginStatus.installed, + PluginStatus.disabled, + PluginStatus.permission_required, + }: + raise ExtensionError( + "PLUGIN_HOST_UNAVAILABLE", + "Disabled or inactive MCP Plugins must be started with Enable.", + status_code=409, + details={"plugin_id": plugin_id, "status": record.plugin.status.value}, + ) for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() @@ -664,7 +675,9 @@ def _arguments_model_from_schema( annotation = types.get(field_schema.get("type"), Any) fields[name] = (annotation, ... if name in required else None) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name) - return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields) + # 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 + # Pydantic 再次拒绝 additionalProperties/patternProperties 接受的合法参数。 + return create_model(model_name, __config__=ConfigDict(extra="allow"), **fields) def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py index 6cf43e1..59f2be9 100644 --- a/backend/extensions/fixtures/mcp-echo/server.py +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -135,6 +135,15 @@ def main() -> None: request_id = message.get("id") params = message.get("params") or {} if method == "initialize" and isinstance(request_id, int): + if MODE == "invalid-result": + send({"jsonrpc": "2.0", "id": request_id, "result": None}) + continue + if MODE == "oversized-stdout": + # 不带换行,验证 Host 在读取完整内容前执行硬上限。 + sys.stdout.write("x" * (2 * 1024 * 1024 + 1)) + sys.stdout.flush() + time.sleep(10) + return respond( request_id, { diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 1c3274a..4e0a9a8 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -1,5 +1,6 @@ import asyncio import shutil +import threading import time import pytest @@ -14,6 +15,8 @@ from app.contracts import ( ToolCall, ) from app.extensions import ExtensionError +from app.extensions.mcp import McpStdioClient +from app.extensions.runtime import _arguments_model_from_schema from app.services import note_service from app.config import BACKEND_DIR, get_settings @@ -362,6 +365,11 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( assert disabled.status == "disabled" assert mcp_container.plugins.get_host_status("mcp-fixture").status == "stopped" assert not mcp_container.tools.contains("mcp-fixture.echo") + with pytest.raises(ExtensionError) as exc: + mcp_container.plugins.restart_host("mcp-fixture") + assert exc.value.code == "PLUGIN_HOST_UNAVAILABLE" + assert mcp_container.plugins.get("mcp-fixture").status == "disabled" + assert not mcp_container.tools.contains("mcp-fixture.echo") run(scenario()) @@ -438,6 +446,56 @@ def test_mcp_business_error_size_limit_and_timeout_are_structured(mcp_container) run(scenario()) +def test_mcp_cancel_releases_blocking_response_thread( + mcp_container, monkeypatch +) -> None: + async def scenario() -> None: + mcp_container.plugins.enable("mcp-fixture") + released = threading.Event() + original_wait = McpStdioClient.wait_response + + def tracked_wait(self, *args, **kwargs): + try: + return original_wait(self, *args, **kwargs) + finally: + released.set() + + monkeypatch.setattr(McpStdioClient, "wait_response", tracked_wait) + task = asyncio.create_task( + mcp_container.plugins.mcp.call_tool( + "mcp-fixture", + "sleep", + {"seconds": 5}, + request_id="call_cancel_release", + ) + ) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + deadline = time.monotonic() + 0.5 + while not released.is_set() and time.monotonic() < deadline: + await asyncio.sleep(0.01) + assert released.is_set(), "cancelled MCP wait must not occupy a worker until timeout" + + run(scenario()) + + +def test_mcp_argument_model_preserves_json_schema_additional_properties() -> None: + arguments_model = _arguments_model_from_schema( + "mcp-fixture.dynamic", + { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + ) + + arguments = arguments_model.model_validate({"dynamic_key": "value"}) + + assert arguments.model_dump() == {"dynamic_key": "value"} + + def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None: async def scenario() -> None: mcp_container.plugins.enable("mcp-fixture") @@ -471,9 +529,11 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) [ ("no-tools", "[]", "MCP_CAPABILITY_UNSUPPORTED"), ("invalid-schema", "[mcp-invalid.broken]", "MCP_TOOL_SCHEMA_INVALID"), + ("invalid-result", "[]", "MCP_INITIALIZE_FAILED"), + ("oversized-stdout", "[]", "PLUGIN_HOST_UNAVAILABLE"), ], ) -def test_mcp_rejects_missing_capability_and_invalid_discovery( +def test_mcp_rejects_invalid_initialization_and_discovery( tmp_path, mode, contributions, expected_code ) -> None: package = tmp_path / f"mcp-{mode}" diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index 622abdc..6cdde3f 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -2323,7 +2323,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 87 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 91 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index e639636..ea080e8 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -176,7 +176,7 @@ RunCancelled ## 当前实现状态 -更新至 2026-09-01:后端 87 项回归测试通过。 +更新至 2026-09-01:后端 91 项回归测试通过。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 7428e42..c2f4ecd 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -427,7 +427,7 @@ class McpBridge(Protocol): async def stop(self, plugin_id: str) -> None: ... ``` -首个实现已支持本地 `stdio`,按 MCP `2025-11-25` 发起 initialize,并兼容 `2025-06-18`、`2025-03-26` 和 `2024-11-05` 协商结果。Host 负责 capability negotiation、分页 `tools/list`、进程生命周期、超时取消、stderr 隔离和异常退出后的 Tool 注销。stdio 消息使用 UTF-8 单行 JSON-RPC;当前不实现 Streamable HTTP。 +首个实现已支持本地 `stdio`,按 MCP `2025-11-25` 发起 initialize,并兼容 `2025-06-18`、`2025-03-26` 和 `2024-11-05` 协商结果。Host 负责 capability negotiation、分页 `tools/list`、进程生命周期、超时取消、stderr 隔离和异常退出后的 Tool 注销。stdio 消息使用 UTF-8 单行 JSON-RPC,并在完整行进入内存前执行有界读取;当前不实现 Streamable HTTP。 MCP Plugin 的 `backend` 增加: @@ -482,7 +482,7 @@ error `POST /api/plugins/{plugin_id}/host/restart` 返回 `202 OperationResponse`。重启期间先注销旧 Tool,发现和校验全部成功后再一次性发布新 Tool 集合,避免半注册状态。 -当前实现还返回协商后的 `protocol_version`、`server_name` 和 `server_version`。单条协议消息上限为 2 MiB,单次 Tool Result 上限为 256 KiB;超限分别按 Host/Result 错误处理。Server 异常退出或发送无效 stdout 消息时,Host 进入 `unhealthy`,Plugin 进入 `error`,相关 Tool 立即注销。 +当前实现还返回协商后的 `protocol_version`、`server_name` 和 `server_version`。单条协议消息上限为 2 MiB,单次 Tool Result 上限为 256 KiB;超限分别按 Host/Result 错误处理。Server 异常退出或发送无效 stdout 消息时,Host 进入 `unhealthy`,Plugin 进入 `error`,相关 Tool 立即注销。取消会同时通知 Server、移除 pending request 并唤醒本地等待线程。Restart 不得把 `installed`、`disabled` 或 `permission_required` Plugin 隐式启用,这些状态必须走 Enable。 ### 7.3 Command Contribution 列表 diff --git a/docs/development/AI-Core与Agent-Core开发说明.md b/docs/development/AI-Core与Agent-Core开发说明.md index e3cf687..4bc716f 100644 --- a/docs/development/AI-Core与Agent-Core开发说明.md +++ b/docs/development/AI-Core与Agent-Core开发说明.md @@ -2,7 +2,7 @@ > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 -> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 87 项测试通过。 +> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 91 项测试通过。 ## 当前实现 diff --git a/docs/development/Knowledge与Retrieval-Core开发说明.md b/docs/development/Knowledge与Retrieval-Core开发说明.md index 2fcd38c..24f2e63 100644 --- a/docs/development/Knowledge与Retrieval-Core开发说明.md +++ b/docs/development/Knowledge与Retrieval-Core开发说明.md @@ -3,7 +3,7 @@ > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 -> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 87 项测试通过。 +> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 91 项测试通过。 ## 当前实现 diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md index 61bb74b..4828ade 100644 --- a/docs/development/MCP-Bridge与Plugin-Host开发说明.md +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -146,6 +146,7 @@ MCP Tool: - 远端名称必须能转换为合法且稳定的项目 Tool ID; - `inputSchema` 必须是有效的 object JSON Schema; +- `additionalProperties`、`patternProperties` 等动态字段先由完整 JSON Schema 校验,Pydantic 参数载体不会再次误拒绝合法字段; - `_meta.notesagent/permission` 必须属于项目已知权限; - Tool 权限必须同时出现在 Plugin Manifest 中; - Agent 仍通过 Tool Registry 执行参数校验、Permission、超时和 Trace; @@ -165,9 +166,10 @@ MCP Tool: - 不把 Provider API Key、`APP_DB_PATH`、Vault 路径和其他宿主环境变量传入子进程; - stderr 与 JSON-RPC stdout 分离,stderr 不进入 API 和 Agent Trace; - stdout 只能发送合法 MCP JSON-RPC; -- 单条协议消息上限 2 MiB; +- stdout 在读取完整行前即应用有界读取,单条协议消息上限 2 MiB;stderr 也按固定大小分块读取; - 单次 Tool Result 上限 256 KiB; - MCP Tool 不绕过 Permission Manager 和 Agent Tool Timeout。 +- 调用被 Agent 取消时,同时通知 Server 并唤醒本地 pending Queue,阻塞线程不会继续占用线程池直至远端超时。 当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发前必须继续增加包签名、来源验证和平台级沙箱;不得把当前进程隔离描述为完全安全执行任意不可信代码。 @@ -201,7 +203,7 @@ unhealthy error ``` -Restart 返回 `202 OperationResponse`。接口返回前已完成本地 Host 重启和 Tool 重新发现;`message` 中给出最终 Host 状态。 +Restart 返回 `202 OperationResponse`。接口返回前已完成本地 Host 重启和 Tool 重新发现;`message` 中给出最终 Host 状态。Restart 只用于运行中或异常 Host;用户主动停用、尚未启用或等待授权的 Plugin 返回 `409 PLUGIN_HOST_UNAVAILABLE`,必须通过 Enable 明确启动。 ## 8. 离线 Fixture @@ -220,7 +222,7 @@ backend/extensions/fixtures/mcp-echo - `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程; - `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。 -Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability 和返回无效 Schema 的变体。 +Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。 ## 9. 验证 @@ -241,10 +243,13 @@ pnpm build - 分页 `tools/list` 与命名空间映射; - Permission、JSON Schema 与 Contribution 集合; - Tool 成功、业务错误、结果过大和超时; +- Agent 取消后 pending 等待线程及时释放; +- `additionalProperties` 动态参数保持 JSON Schema 语义; - Agent Runtime 调用 MCP Tool 并写入正式 Trace; - Secret/Vault 环境隔离; - Server 异常退出、Tool 注销和 Host Restart; -- 缺少 capability 与无效 MCP Schema; +- 缺少 capability、无效 initialize result、无效 MCP Schema 和超长无换行 stdout; +- disabled Plugin 不会被 Host Restart 隐式重新启用; - OpenAPI 发布 Host 状态和重启路径。 ## 10. 当前边界与后续阶段 diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index de7fc1a..b943dee 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -187,12 +187,12 @@ pnpm build ```text pnpm build passed pnpm test 27 passed -uv run pytest 87 passed +uv run pytest 91 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 87 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 91 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/development/模型提供商与模型发现开发说明.md b/docs/development/模型提供商与模型发现开发说明.md index 0d72d9a..54cfae0 100644 --- a/docs/development/模型提供商与模型发现开发说明.md +++ b/docs/development/模型提供商与模型发现开发说明.md @@ -104,4 +104,4 @@ pnpm build 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 -当前完整回归基线:后端 87 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 +当前完整回归基线:后端 91 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 diff --git a/docs/retrospectives/后端全面审阅问题与修复复盘.md b/docs/retrospectives/后端全面审阅问题与修复复盘.md index 401fa16..fe03208 100644 --- a/docs/retrospectives/后端全面审阅问题与修复复盘.md +++ b/docs/retrospectives/后端全面审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 -> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 87 项测试通过。 +> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 91 项测试通过。 ## 1. 审阅结论 From 574b113827788a1801338bed508cf868a7a7dadf Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 16:06:23 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(extension):=20=E5=AE=8C=E5=96=84=20MCP?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=E4=B8=8E=E8=BF=90=E8=A1=8C=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/extensions/mcp.py | 12 ++++++++++ backend/app/extensions/runtime.py | 16 ++++++++++---- .../extensions/fixtures/mcp-echo/server.py | 16 ++++++++++++-- backend/tests/test_extension_core.py | 22 +++++++++++++++++++ .../AI笔记软件技术栈说明-团队版-v2.3.md | 4 ++++ docs/contracts/第二阶段接口契约-开发版.md | 6 ++--- .../MCP-Bridge与Plugin-Host开发说明.md | 21 ++++++++++++++---- 7 files changed, 84 insertions(+), 13 deletions(-) diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py index d5036a7..68f056b 100644 --- a/backend/app/extensions/mcp.py +++ b/backend/app/extensions/mcp.py @@ -91,6 +91,8 @@ class McpStdioClient: def start(self) -> None: if self.process is not None and self.process.poll() is None: return + # TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的 + # 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。 creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 environment = _subprocess_environment() environment.setdefault("PYTHONUNBUFFERED", "1") @@ -567,6 +569,16 @@ class McpBridge: host.status.tools_count = 0 host.status.error = None + def remove(self, plugin_id: str) -> None: + """停止 Host,并清除卸载后不应跨安装保留的状态与调用索引。""" + + self.stop(plugin_id) + with self._lock: + self._statuses.pop(plugin_id, None) + stale_calls = [key for key in self._calls if key[0] == plugin_id] + for key in stale_calls: + self._calls.pop(key, None) + def status(self, plugin_id: str, backend: PluginBackend) -> PluginHostStatus: with self._lock: status = self._statuses.get(plugin_id) diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index fa6f72f..cacbb12 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -536,7 +536,9 @@ class PluginRuntime: return await self.mcp.call_tool( plugin_id, remote_name, - arguments.model_dump(), + # 省略的可选字段不能被补成 null;显式传入的 null 仍由 + # model_fields_set 保留并交给 MCP Server。 + arguments.model_dump(exclude_unset=True), request_id=context.tool_call_id or f"{context.run_id}:{definition.name}", ) @@ -572,10 +574,13 @@ class PluginRuntime: status_code=409, details={"plugin_id": plugin_id, "skills": dependent_skills}, ) + is_mcp = record.plugin.manifest.backend.type == "mcp" if record.plugin.enabled: self.disable(plugin_id) - elif record.plugin.manifest.backend.type == "mcp": - self.mcp.stop(plugin_id) + if is_mcp: + # stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同 + # 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。 + self.mcp.remove(plugin_id) del self._records[plugin_id] def _record(self, plugin_id: str) -> _PluginRecord: @@ -672,7 +677,10 @@ def _arguments_model_from_schema( "object": dict[str, Any], } for name, field_schema in properties.items(): - annotation = types.get(field_schema.get("type"), Any) + schema_type = field_schema.get("type") + # JSON Schema 允许联合类型数组;复杂类型继续由 Draft Validator + # 精确校验,Pydantic 在这里只承担参数载体职责。 + annotation = types.get(schema_type, Any) if isinstance(schema_type, str) else Any fields[name] = (annotation, ... if name in required else None) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name) # 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py index 59f2be9..b1fefda 100644 --- a/backend/extensions/fixtures/mcp-echo/server.py +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -39,7 +39,14 @@ def tool(name: str, description: str, properties: dict[str, Any] | None = None) TOOLS = { "echo": { - **tool("echo", "Return the provided text.", {"text": {"type": "string"}}), + **tool( + "echo", + "Return the provided text.", + { + "text": {"type": "string"}, + "suffix": {"type": ["string", "null"]}, + }, + ), "_meta": {"notesagent/permission": "notes.read"}, }, "fail": tool("fail", "Return an MCP business error."), @@ -48,6 +55,8 @@ TOOLS = { "environment": tool("environment", "Report whether host secrets leaked into the process."), "exit": tool("exit", "Terminate the fixture process."), } +# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。 +TOOLS["echo"]["inputSchema"]["required"] = ["text"] def call_tool(request_id: int, params: dict[str, Any]) -> None: @@ -55,11 +64,14 @@ def call_tool(request_id: int, params: dict[str, Any]) -> None: arguments = params.get("arguments") or {} if name == "echo": text = str(arguments.get("text", "")) + structured_content = {"echo": text} + if "suffix" in arguments: + structured_content["suffix"] = arguments["suffix"] respond( request_id, { "content": [{"type": "text", "text": text}], - "structuredContent": {"echo": text}, + "structuredContent": structured_content, "isError": False, }, ) diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 4e0a9a8..5b368aa 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -355,6 +355,19 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( assert definition.permission == "notes.read" assert result.success is True assert result.output == {"echo": "hello mcp"} + explicit_null = await mcp_container.tools.execute( + ToolCall( + tool_call_id="call_mcp_explicit_null", + name="mcp-fixture.echo", + arguments={"text": "null stays explicit", "suffix": None}, + ), + ToolExecutionContext(run_id="run_mcp_fixture"), + ) + assert explicit_null.success is True + assert explicit_null.output == { + "echo": "null stays explicit", + "suffix": None, + } assert environment.success is True assert environment.output == { "has_openai_key": False, @@ -371,6 +384,15 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( assert mcp_container.plugins.get("mcp-fixture").status == "disabled" assert not mcp_container.tools.contains("mcp-fixture.echo") + mcp_container.plugins.uninstall("mcp-fixture") + reinstalled = mcp_container.plugins.install(MCP_FIXTURE) + fresh_status = mcp_container.plugins.get_host_status("mcp-fixture") + assert reinstalled.status == "permission_required" + assert fresh_status.status == "stopped" + assert fresh_status.started_at is None + assert fresh_status.protocol_version is None + assert fresh_status.server_name is None + run(scenario()) diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index 6cdde3f..dc4ad1a 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -1040,6 +1040,10 @@ Plugin Host 负责: 内置 Plugin 可以使用相同的 Plugin Interface 注册能力,减少内置功能和社区扩展之间的接口差异。 +Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --from == ` 启动,以隔离依赖并避免污染 AI Core 环境;包内脚本和非 Python Server 仍可使用受控 `command + args`。`uvx` 的虚拟环境不是安全沙箱,不能限制文件、网络、子进程或系统调用。 + +面向社区或不可信 Plugin 开放前,Tauri/Rust Host 必须增加平台级沙箱、完整进程树回收、包来源/签名校验,并在首次安装或命令变化时向用户完整展示 executable 和参数、要求明确同意。当前 Python Host 的独立进程、环境裁剪和 Permission 只用于可信开发联调,不能替代这些生产安全门槛。 + ### 12.5 MCP Bridge MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index c2f4ecd..6c70504 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -435,13 +435,13 @@ MCP Plugin 的 `backend` 增加: backend: type: mcp transport: stdio - command: python - args: [server.py] + command: uvx + args: [--isolated, --from, example-mcp==1.2.3, example-mcp] startup_timeout_seconds: 5 tool_timeout_seconds: 30 ``` -命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY`、`APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 +命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。Python 包形式的 MCP 推荐使用固定版本的 `uvx --isolated --from`,但 `uvx` 只隔离依赖而不是文件/网络/系统调用安全沙箱,非 Python Server 不强制使用。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY`、`APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 远端 Tool 的可选项目权限放在 MCP `_meta`: diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md index 4828ade..7514142 100644 --- a/docs/development/MCP-Bridge与Plugin-Host开发说明.md +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -67,8 +67,8 @@ contributes: backend: type: mcp transport: stdio - command: python - args: [server.py] + command: uvx + args: [--isolated, --from, example-mcp==1.2.3, example-mcp] startup_timeout_seconds: 5 tool_timeout_seconds: 30 ``` @@ -77,7 +77,9 @@ backend: - 阶段 C 只接受 `type: mcp` 与 `transport: stdio`; - 命令和参数通过数组直接传给 `subprocess.Popen`,不经过 Shell; -- PATH 中的 executable 使用名称,例如 `python`、`node`; +- Python 包形式的 MCP Server 推荐使用 `uvx --isolated --from == `,固定版本并与 NotesAgent 项目环境隔离; +- Plugin 包内自带且不需要第三方依赖的 Python 脚本可以使用 `python server.py`;Node、Rust 等 Server 继续使用各自受控启动器,因此 Host 不强制所有 MCP 都经过 `uvx`; +- PATH 中的 executable 使用名称,例如 `uvx`、`python`、`node`; - manifest 中带目录的 executable 必须解析到 Plugin 包内部; - `contributes.tools` 使用 `.`; - 安装阶段只读 Manifest,不启动第三方进程; @@ -158,6 +160,8 @@ MCP Tool: 当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。 +`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。首次解析尚未缓存的包还可能访问包索引;生产清单必须固定来源和版本,安装/更新阶段与运行阶段分离。 + 已经执行的保护: - 第三方模块不 import 到 AI Core; @@ -171,7 +175,15 @@ MCP Tool: - MCP Tool 不绕过 Permission Manager 和 Agent Tool Timeout。 - 调用被 Agent 取消时,同时通知 Server 并唤醒本地 pending Queue,阻塞线程不会继续占用线程池直至远端超时。 -当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发前必须继续增加包签名、来源验证和平台级沙箱;不得把当前进程隔离描述为完全安全执行任意不可信代码。 +当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发或“一键安装”前必须完成以下安全门槛: + +- 由 Tauri/Rust Host 统一启动进程并提供平台级文件、网络、子进程和资源配额限制; +- 安装/更新时完整展示 executable 与全部参数,明确警告并要求用户主动确认; +- 固定包来源和版本,增加包哈希/签名与可信发布者校验; +- 默认禁止访问 Vault、凭据和宿主环境,只通过声明 Permission 与受控 Host API 授权; +- 关闭 Host 时终止完整进程树,不只结束直接子进程。 + +在这些门槛完成前,当前 MCP Host 只适用于内置 Fixture、团队可信插件和开发联调;不得把它描述为可以安全执行任意社区代码。上述安装确认要求遵循 MCP [SEP-1024](https://modelcontextprotocol.io/seps/1024-mcp-client-security-requirements-for-local-server-);`uvx` 行为依据 uv 官方 [Using tools](https://docs.astral.sh/uv/guides/tools/) 文档。 ## 7. Host API @@ -262,6 +274,7 @@ pnpm build - Secret Reference 注入; - Plugin Registry 持久化、签名与社区来源校验; - 操作系统级沙箱; +- 一键安装前的完整命令展示与确认 UI; - Tool 列表热更新的无中断替换。 阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。 From e37ac7b0a474b5027a488286289df20b3220557b Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 21:25:05 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(extension):=20=E5=BC=BA=E5=8C=96=20MCP?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=E4=B8=8E=E7=94=9F=E4=BA=A7=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/README.md | 2 +- backend/app/container.py | 10 ++++-- backend/app/extensions/runtime.py | 36 +++++++++---------- backend/tests/test_extension_core.py | 29 +++++++++++++-- .../AI笔记软件技术栈说明-团队版-v2.3.md | 4 ++- docs/contracts/后端接口契约-开发版.md | 2 +- docs/contracts/第二阶段接口契约-开发版.md | 7 ++-- .../AI-Core与Agent-Core开发说明.md | 2 +- .../Knowledge与Retrieval-Core开发说明.md | 2 +- .../MCP-Bridge与Plugin-Host开发说明.md | 6 ++-- docs/development/前端壳子与接口层开发说明.md | 4 +-- .../模型提供商与模型发现开发说明.md | 2 +- .../后端全面审阅问题与修复复盘.md | 2 +- 14 files changed, 72 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index ac7a7db..10d735b 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 91 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/README.md b/backend/README.md index 6a0930e..3d4fde4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 91 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/container.py b/backend/app/container.py index 0cff70a..9263ca9 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent.builtin_tools import register_builtin_tools from app.contracts import ModelCapability, ProviderConfig, ProviderType -from app.config import BACKEND_DIR +from app.config import BACKEND_DIR, get_settings from app.extensions import PluginRuntime, SkillRuntime from app.providers import MockProvider, ProviderFactory, ProviderRegistry from app.providers.credentials import ( @@ -26,6 +26,7 @@ class ApplicationContainer: def build_container() -> ApplicationContainer: + settings = get_settings() credentials = EncryptedCredentialStore() provider_factory = ProviderFactory( ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) @@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer: tools = ToolRegistry() register_builtin_tools(tools) - plugins = PluginRuntime(tools) + plugins = PluginRuntime( + tools, + # 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到 + # Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。 + allow_unsandboxed_mcp=settings.environment == "development", + ) plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools") plugins.enable("text-tools") diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index cacbb12..832b726 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -256,10 +256,13 @@ class PluginRuntime: tools: ToolRegistry, host: DeclarativePluginHost | None = None, mcp_bridge: McpBridge | None = None, + *, + allow_unsandboxed_mcp: bool = False, ) -> None: self.registry = tools self.host = host or DeclarativePluginHost() self.mcp = mcp_bridge or McpBridge() + self.allow_unsandboxed_mcp = allow_unsandboxed_mcp self._records: dict[str, _PluginRecord] = {} self._lock = threading.RLock() @@ -346,6 +349,16 @@ class PluginRuntime: status_code=409, details={"plugin_id": plugin_id, "permissions": missing_grants}, ) + if ( + record.plugin.manifest.backend.type == "mcp" + and not self.allow_unsandboxed_mcp + ): + raise ExtensionError( + "MCP_TRUST_APPROVAL_REQUIRED", + "Unsandboxed MCP Hosts are disabled outside development mode.", + status_code=403, + details={"plugin_id": plugin_id}, + ) declared_tools = list(record.plugin.manifest.contributes.tools) conflicts = [name for name in declared_tools if self.registry.contains(name)] if conflicts: @@ -665,27 +678,10 @@ def _arguments_model_from_schema( ) -> type[BaseModel]: if schema.get("type", "object") != "object": raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.") - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - fields: dict[str, tuple[Any, Any]] = {} - types = { - "string": str, - "number": float, - "integer": int, - "boolean": bool, - "array": list[Any], - "object": dict[str, Any], - } - for name, field_schema in properties.items(): - schema_type = field_schema.get("type") - # JSON Schema 允许联合类型数组;复杂类型继续由 Draft Validator - # 精确校验,Pydantic 在这里只承担参数载体职责。 - annotation = types.get(schema_type, Any) if isinstance(schema_type, str) else Any - fields[name] = (annotation, ... if name in required else None) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name) - # 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 - # Pydantic 再次拒绝 additionalProperties/patternProperties 接受的合法参数。 - return create_model(model_name, __config__=ConfigDict(extra="allow"), **fields) + # 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段, + # 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。 + return create_model(model_name, __config__=ConfigDict(extra="allow")) def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 5b368aa..29a2d92 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -509,13 +509,38 @@ def test_mcp_argument_model_preserves_json_schema_additional_properties() -> Non "mcp-fixture.dynamic", { "type": "object", + "properties": {"model_dump": {"type": "string"}}, + "required": ["model_dump"], "additionalProperties": {"type": "string"}, }, ) - arguments = arguments_model.model_validate({"dynamic_key": "value"}) + arguments = arguments_model.model_validate( + {"model_dump": "method name remains data", "dynamic-key": "value"} + ) - assert arguments.model_dump() == {"dynamic_key": "value"} + assert arguments.model_dump() == { + "model_dump": "method name remains data", + "dynamic-key": "value", + } + + +def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None: + monkeypatch.setenv("APP_ENVIRONMENT", "production") + get_settings.cache_clear() + container = build_container() + installed = container.plugins.install(MCP_FIXTURE) + assert installed.status == "permission_required" + container.plugins.set_permissions("mcp-fixture", ["notes.read"]) + try: + with pytest.raises(ExtensionError) as exc: + container.plugins.enable("mcp-fixture") + assert exc.value.code == "MCP_TRUST_APPROVAL_REQUIRED" + assert container.plugins.get_host_status("mcp-fixture").status == "stopped" + assert not container.tools.contains("mcp-fixture.echo") + finally: + container.plugins.shutdown() + get_settings.cache_clear() def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None: diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index dc4ad1a..47b6fe7 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -1044,6 +1044,8 @@ Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --fro 面向社区或不可信 Plugin 开放前,Tauri/Rust Host 必须增加平台级沙箱、完整进程树回收、包来源/签名校验,并在首次安装或命令变化时向用户完整展示 executable 和参数、要求明确同意。当前 Python Host 的独立进程、环境裁剪和 Permission 只用于可信开发联调,不能替代这些生产安全门槛。 +在该门槛完成前,后端仅允许 `APP_ENVIRONMENT=development` 启动未沙箱化 MCP Host;生产环境统一返回 `MCP_TRUST_APPROVAL_REQUIRED`。Python `uvx` Server 在开发模式首次运行可能联网解析依赖,生产版本必须在安装/更新阶段预取并验证固定版本,正常运行阶段只使用已经准备好的环境。 + ### 12.5 MCP Bridge MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。 @@ -2327,7 +2329,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 91 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index ea080e8..099a666 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -176,7 +176,7 @@ RunCancelled ## 当前实现状态 -更新至 2026-09-01:后端 91 项回归测试通过。 +更新至 2026-09-01:后端 92 项回归测试通过。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 6c70504..68e0d9b 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -437,11 +437,13 @@ backend: transport: stdio command: uvx args: [--isolated, --from, example-mcp==1.2.3, example-mcp] - startup_timeout_seconds: 5 + startup_timeout_seconds: 60 tool_timeout_seconds: 30 ``` -命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。Python 包形式的 MCP 推荐使用固定版本的 `uvx --isolated --from`,但 `uvx` 只隔离依赖而不是文件/网络/系统调用安全沙箱,非 Python Server 不强制使用。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY`、`APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 +命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。Python 包形式的 MCP 推荐使用固定版本的 `uvx --isolated --from`,但 `uvx` 只隔离依赖而不是文件/网络/系统调用安全沙箱,非 Python Server 不强制使用。开发模式首次运行未缓存的 uvx 包可能联网解析,因此 startup 示例使用 60 秒;生产安装阶段必须预取并验证,运行阶段不得临时解析依赖。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY`、`APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 + +在平台沙箱和可信命令许可完成前,`APP_ENVIRONMENT != development` 时启用 MCP Plugin 必须返回 `403 MCP_TRUST_APPROVAL_REQUIRED`,不得启动进程或注册 Tool。该门禁由后端执行,不能只依赖前端提示或文档约定。 远端 Tool 的可选项目权限放在 MCP `_meta`: @@ -632,6 +634,7 @@ MCP_CAPABILITY_UNSUPPORTED MCP_TOOL_SCHEMA_INVALID MCP_TOOL_CALL_FAILED MCP_TOOL_RESULT_TOO_LARGE +MCP_TRUST_APPROVAL_REQUIRED PLUGIN_COMMAND_NOT_FOUND PLUGIN_COMMAND_CONTEXT_INVALID PLUGIN_SETTINGS_SCHEMA_INVALID diff --git a/docs/development/AI-Core与Agent-Core开发说明.md b/docs/development/AI-Core与Agent-Core开发说明.md index 4bc716f..fc79e05 100644 --- a/docs/development/AI-Core与Agent-Core开发说明.md +++ b/docs/development/AI-Core与Agent-Core开发说明.md @@ -2,7 +2,7 @@ > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 -> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 91 项测试通过。 +> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。 ## 当前实现 diff --git a/docs/development/Knowledge与Retrieval-Core开发说明.md b/docs/development/Knowledge与Retrieval-Core开发说明.md index 24f2e63..8aee584 100644 --- a/docs/development/Knowledge与Retrieval-Core开发说明.md +++ b/docs/development/Knowledge与Retrieval-Core开发说明.md @@ -3,7 +3,7 @@ > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 -> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 91 项测试通过。 +> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。 ## 当前实现 diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md index 7514142..bd43a94 100644 --- a/docs/development/MCP-Bridge与Plugin-Host开发说明.md +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -69,7 +69,7 @@ backend: transport: stdio command: uvx args: [--isolated, --from, example-mcp==1.2.3, example-mcp] - startup_timeout_seconds: 5 + startup_timeout_seconds: 60 tool_timeout_seconds: 30 ``` @@ -160,7 +160,7 @@ MCP Tool: 当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。 -`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。首次解析尚未缓存的包还可能访问包索引;生产清单必须固定来源和版本,安装/更新阶段与运行阶段分离。 +`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。当前开发模式下,首次 `enable` 尚未缓存的包可能访问包索引,因此示例使用 60 秒启动上限;生产实现不得依赖该行为,必须在用户确认后的安装/更新阶段预取和验证固定版本,运行阶段只启动已准备好的环境。 已经执行的保护: @@ -185,6 +185,8 @@ MCP Tool: 在这些门槛完成前,当前 MCP Host 只适用于内置 Fixture、团队可信插件和开发联调;不得把它描述为可以安全执行任意社区代码。上述安装确认要求遵循 MCP [SEP-1024](https://modelcontextprotocol.io/seps/1024-mcp-client-security-requirements-for-local-server-);`uvx` 行为依据 uv 官方 [Using tools](https://docs.astral.sh/uv/guides/tools/) 文档。 +后端通过 `APP_ENVIRONMENT` 强制该边界:只有 `development` 可以启动当前未沙箱化的 MCP Host;其他环境返回 `403 MCP_TRUST_APPROVAL_REQUIRED`,且不会创建进程或注册 Tool。后续 Tauri/Rust Host 提供沙箱与绑定完整命令摘要的可信许可后,再替换此临时门禁。 + ## 7. Host API ```http diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index b943dee..40a5dd3 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -187,12 +187,12 @@ pnpm build ```text pnpm build passed pnpm test 27 passed -uv run pytest 91 passed +uv run pytest 92 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 91 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/development/模型提供商与模型发现开发说明.md b/docs/development/模型提供商与模型发现开发说明.md index 54cfae0..598ede8 100644 --- a/docs/development/模型提供商与模型发现开发说明.md +++ b/docs/development/模型提供商与模型发现开发说明.md @@ -104,4 +104,4 @@ pnpm build 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 -当前完整回归基线:后端 91 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 +当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 diff --git a/docs/retrospectives/后端全面审阅问题与修复复盘.md b/docs/retrospectives/后端全面审阅问题与修复复盘.md index fe03208..59fa47e 100644 --- a/docs/retrospectives/后端全面审阅问题与修复复盘.md +++ b/docs/retrospectives/后端全面审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 -> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 91 项测试通过。 +> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。 ## 1. 审阅结论