From 574b113827788a1801338bed508cf868a7a7dadf Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 1 Sep 2026 16:06:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(extension):=20=E5=AE=8C=E5=96=84=20MCP=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=8E=E8=BF=90=E8=A1=8C=E5=AE=89=E5=85=A8?= =?UTF-8?q?=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 的原则。