From 199dd25c3e224841aa34f94a9635d418d1226a68 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 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 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())