feat: benchmark功能开发完成

This commit is contained in:
yxx
2026-09-01 23:30:11 +08:00
29 changed files with 2123 additions and 116 deletions
+2 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test
```
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 92 项测试、前端 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 恢复、事件契约与脱敏问题复盘 |
+1 -1
View File
@@ -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_<ID>` 注入;不要把真实密钥写入仓库。
当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+7 -1
View File
@@ -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:
+44 -18
View File
@@ -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,
+8 -2
View File
@@ -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")
+26
View File
@@ -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)
+9 -1
View File
@@ -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",
]
+785
View File
@@ -0,0 +1,785 @@
"""本地 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
# 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")
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(
response_error_code, "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, wake_error: BaseException | None = None
) -> None:
with self._pending_lock:
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
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:
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
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:
while True:
# stderr 不是协议通道,但同样按块读取,避免无换行日志造成
# 宿主侧的无界字符串分配。
line = process.stderr.readline(1025)
if line == "":
break
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,
McpBridgeError(
"MCP_TOOL_CALL_FAILED", "MCP request was cancelled."
),
)
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 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)
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
+252 -59
View File
@@ -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,29 @@ 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,
*,
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()
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 +285,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 +318,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 +330,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 +349,18 @@ 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)]
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:
raise ExtensionError(
"PLUGIN_TOOL_CONFLICT",
@@ -346,42 +370,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 +460,125 @@ 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},
)
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()
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,
# 省略的可选字段不能被补成 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}",
)
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(
@@ -420,8 +587,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)
if is_mcp:
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord:
@@ -498,24 +670,18 @@ 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", {})
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():
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)
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段,
# 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。
return create_model(model_name, __config__=ConfigDict(extra="allow"))
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
@@ -536,3 +702,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__}."
+12
View File
@@ -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(
+43 -4
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from uuid import uuid4
@@ -39,6 +40,7 @@ from app.contracts import (
PageMeta,
PermissionDecisionRequest,
Plugin,
PluginHostStatus,
PluginListResponse,
PluginPermissionGrantRequest,
ProviderConfig,
@@ -139,6 +141,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:
@@ -492,7 +503,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(
@@ -501,7 +512,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(
@@ -512,11 +523,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,
@@ -525,7 +562,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")
@@ -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
@@ -0,0 +1,204 @@
"""确定性的 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"},
"suffix": {"type": ["string", "null"]},
},
),
"_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."),
}
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
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", ""))
structured_content = {"echo": text}
if "suffix" in arguments:
structured_content["suffix"] = arguments["suffix"]
respond(
request_id,
{
"content": [{"type": "text", "text": text}],
"structuredContent": structured_content,
"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):
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,
{
"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()
+2
View File
@@ -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",
+318 -1
View File
@@ -1,4 +1,7 @@
import asyncio
import shutil
import threading
import time
import pytest
@@ -12,14 +15,31 @@ 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 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 +317,300 @@ 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"}
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,
"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")
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")
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())
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_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",
"properties": {"model_dump": {"type": "string"}},
"required": ["model_dump"],
"additionalProperties": {"type": "string"},
},
)
arguments = arguments_model.model_validate(
{"model_dump": "method name remains data", "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:
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"),
("invalid-result", "[]", "MCP_INITIALIZE_FAILED"),
("oversized-stdout", "[]", "PLUGIN_HOST_UNAVAILABLE"),
],
)
def test_mcp_rejects_invalid_initialization_and_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()
+1
View File
@@ -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)
@@ -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 仍未实现。
---
@@ -1040,10 +1040,18 @@ Plugin Host 负责:
内置 Plugin 可以使用相同的 Plugin Interface 注册能力,减少内置功能和社区扩展之间的接口差异。
Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --from <package>==<version> <command>` 启动,以隔离依赖并避免污染 AI Core 环境;包内脚本和非 Python Server 仍可使用受控 `command + args``uvx` 的虚拟环境不是安全沙箱,不能限制文件、网络、子进程或系统调用。
面向社区或不可信 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 接口的插件或外部工具服务。
当前已实现本地 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 +1070,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 +1082,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 +2329,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 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
第二阶段在既有 Contract 上接入:
@@ -2331,7 +2339,7 @@ Multimodal
└── pyannote.audio
Extension / Model
├── MCP Bridge
├── MCP Bridgestdio 首版已实现)
├── Plugin Command Contribution
├── Plugin Settings Contribution
└── Provider Streaming / Tool Calling / Error Mapping 增强
@@ -2353,7 +2361,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 +2417,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。
@@ -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 稳定;
@@ -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:后端 92 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 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 RuntimeAgent 只消费内部 Tool Contract。Host 支持 initialize、分页发现、调用、超时取消、状态查询、重启和异常退出后的 Tool 注销。
- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
@@ -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,31 @@ 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: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 60
tool_timeout_seconds: 30
```
命令通过参数数组直接启动,不经过 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`
```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 +484,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 立即注销。取消会同时通知 Server、移除 pending request 并唤醒本地等待线程。Restart 不得把 `installed``disabled``permission_required` Plugin 隐式启用,这些状态必须走 Enable。
### 7.3 Command Contribution 列表
`GET /api/plugin-contributions/commands?location=command_palette`
@@ -608,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
@@ -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 已落地,后端当前回归基线为 92 项测试通过。
## 当前实现
@@ -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 前统一增加 `<plugin_id>.<remote_name>` 命名空间。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 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
- 当前 Plugin Host 支持内置声明式白名单 handler;MCP Bridge、独立进程健康检查与 UI Contribution 在第二阶段实现
- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP ServerStreamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段
@@ -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;完整后端回归基线为 92 项测试通过。
## 当前实现
@@ -0,0 +1,282 @@
# 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: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 60
tool_timeout_seconds: 30
```
约束:
- 阶段 C 只接受 `type: mcp``transport: stdio`
- 命令和参数通过数组直接传给 `subprocess.Popen`,不经过 Shell
- Python 包形式的 MCP Server 推荐使用 `uvx --isolated --from <package>==<version> <command>`,固定版本并与 NotesAgent 项目环境隔离;
- Plugin 包内自带且不需要第三方依赖的 Python 脚本可以使用 `python server.py`Node、Rust 等 Server 继续使用各自受控启动器,因此 Host 不强制所有 MCP 都经过 `uvx`
- PATH 中的 executable 使用名称,例如 `uvx``python``node`
- manifest 中带目录的 executable 必须解析到 Plugin 包内部;
- `contributes.tools` 使用 `<plugin_id>.<remote_name>`
- 安装阶段只读 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
- `additionalProperties``patternProperties` 等动态字段先由完整 JSON Schema 校验,Pydantic 参数载体不会再次误拒绝合法字段;
- `_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. 隔离与安全边界
当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。
`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。当前开发模式下,首次 `enable` 尚未缓存的包可能访问包索引,因此示例使用 60 秒启动上限;生产实现不得依赖该行为,必须在用户确认后的安装/更新阶段预取和验证固定版本,运行阶段只启动已准备好的环境。
已经执行的保护:
- 第三方模块不 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
- 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 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发或“一键安装”前必须完成以下安全门槛:
- 由 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/) 文档。
后端通过 `APP_ENVIRONMENT` 强制该边界:只有 `development` 可以启动当前未沙箱化的 MCP Host;其他环境返回 `403 MCP_TRUST_APPROVAL_REQUIRED`,且不会创建进程或注册 Tool。后续 Tauri/Rust Host 提供沙箱与绑定完整命令摘要的可信许可后,再替换此临时门禁。
## 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 状态。Restart 只用于运行中或异常 Host;用户主动停用、尚未启用或等待授权的 Plugin 返回 `409 PLUGIN_HOST_UNAVAILABLE`,必须通过 Enable 明确启动。
## 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/initialize result,以及输出超长无换行 stdout 的变体。
## 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 取消后 pending 等待线程及时释放;
- `additionalProperties` 动态参数保持 JSON Schema 语义;
- Agent Runtime 调用 MCP Tool 并写入正式 Trace
- Secret/Vault 环境隔离;
- Server 异常退出、Tool 注销和 Host Restart
- 缺少 capability、无效 initialize result、无效 MCP Schema 和超长无换行 stdout
- disabled Plugin 不会被 Host Restart 隐式重新启用;
- OpenAPI 发布 Host 状态和重启路径。
## 10. 当前边界与后续阶段
阶段 C 不包含:
- Streamable HTTP MCP transport
- Resources、Prompts、Sampling、Elicitation 和 MCP Tasks
- Plugin Command 与 Settings Contribution
- Secret Reference 注入;
- Plugin Registry 持久化、签名与社区来源校验;
- 操作系统级沙箱;
- 一键安装前的完整命令展示与确认 UI;
- Tool 列表热更新的无中断替换。
阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。
@@ -187,12 +187,12 @@ pnpm build
```text
pnpm build passed
pnpm test 27 passed
uv run pytest 81 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 权限警告,不影响 81 项测试结果,也不涉及产品代码。
当前前端使用 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 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -104,4 +104,4 @@ pnpm build
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 81 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
@@ -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,当前完整后端回归基线为 92 项测试通过。
## 1. 审阅结论
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+24 -1
View File
@@ -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
+9 -1
View File
@@ -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<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions }))
}
export async function getPluginHostStatus(pluginId: string): Promise<PluginHostStatus> {
return apiClient.get(`/api/plugins/${pluginId}/host`)
}
export async function restartPluginHost(pluginId: string): Promise<OperationResponse> {
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}