fix(extension): 完善 MCP 参数与运行安全边界
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 中先行校验。这里允许额外字段,避免
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user