fix(extension): 完善 MCP 参数与运行安全边界

This commit is contained in:
2026-09-01 16:06:23 +08:00
parent 20920b6845
commit 199dd25c3e
4 changed files with 60 additions and 6 deletions
+12
View File
@@ -91,6 +91,8 @@ class McpStdioClient:
def start(self) -> None: def start(self) -> None:
if self.process is not None and self.process.poll() is None: if self.process is not None and self.process.poll() is None:
return return
# TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
environment = _subprocess_environment() environment = _subprocess_environment()
environment.setdefault("PYTHONUNBUFFERED", "1") environment.setdefault("PYTHONUNBUFFERED", "1")
@@ -567,6 +569,16 @@ class McpBridge:
host.status.tools_count = 0 host.status.tools_count = 0
host.status.error = None 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: def status(self, plugin_id: str, backend: PluginBackend) -> PluginHostStatus:
with self._lock: with self._lock:
status = self._statuses.get(plugin_id) status = self._statuses.get(plugin_id)
+12 -4
View File
@@ -536,7 +536,9 @@ class PluginRuntime:
return await self.mcp.call_tool( return await self.mcp.call_tool(
plugin_id, plugin_id,
remote_name, 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}", request_id=context.tool_call_id or f"{context.run_id}:{definition.name}",
) )
@@ -572,10 +574,13 @@ class PluginRuntime:
status_code=409, status_code=409,
details={"plugin_id": plugin_id, "skills": dependent_skills}, details={"plugin_id": plugin_id, "skills": dependent_skills},
) )
is_mcp = record.plugin.manifest.backend.type == "mcp"
if record.plugin.enabled: if record.plugin.enabled:
self.disable(plugin_id) self.disable(plugin_id)
elif record.plugin.manifest.backend.type == "mcp": if is_mcp:
self.mcp.stop(plugin_id) # stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
del self._records[plugin_id] del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord: def _record(self, plugin_id: str) -> _PluginRecord:
@@ -672,7 +677,10 @@ def _arguments_model_from_schema(
"object": dict[str, Any], "object": dict[str, Any],
} }
for name, field_schema in properties.items(): 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) fields[name] = (annotation, ... if name in required else None)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 # 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免
+14 -2
View File
@@ -39,7 +39,14 @@ def tool(name: str, description: str, properties: dict[str, Any] | None = None)
TOOLS = { TOOLS = {
"echo": { "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"}, "_meta": {"notesagent/permission": "notes.read"},
}, },
"fail": tool("fail", "Return an MCP business error."), "fail": tool("fail", "Return an MCP business error."),
@@ -48,6 +55,8 @@ TOOLS = {
"environment": tool("environment", "Report whether host secrets leaked into the process."), "environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture 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: 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 {} arguments = params.get("arguments") or {}
if name == "echo": if name == "echo":
text = str(arguments.get("text", "")) text = str(arguments.get("text", ""))
structured_content = {"echo": text}
if "suffix" in arguments:
structured_content["suffix"] = arguments["suffix"]
respond( respond(
request_id, request_id,
{ {
"content": [{"type": "text", "text": text}], "content": [{"type": "text", "text": text}],
"structuredContent": {"echo": text}, "structuredContent": structured_content,
"isError": False, "isError": False,
}, },
) )
+22
View File
@@ -355,6 +355,19 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
assert definition.permission == "notes.read" assert definition.permission == "notes.read"
assert result.success is True assert result.success is True
assert result.output == {"echo": "hello mcp"} 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.success is True
assert environment.output == { assert environment.output == {
"has_openai_key": False, "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 mcp_container.plugins.get("mcp-fixture").status == "disabled"
assert not mcp_container.tools.contains("mcp-fixture.echo") 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()) run(scenario())