fix(extension): 强化 MCP 参数与生产运行门禁

This commit is contained in:
2026-09-01 21:25:05 +08:00
parent 574b113827
commit e37ac7b0a4
14 changed files with 72 additions and 38 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 91 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
+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 uv run pytest
``` ```
当前基线为 91 项测试通过。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` 为准。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+8 -2
View File
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
from app.agent.builtin_tools import register_builtin_tools from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType 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.extensions import PluginRuntime, SkillRuntime
from app.providers import MockProvider, ProviderFactory, ProviderRegistry from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import ( from app.providers.credentials import (
@@ -26,6 +26,7 @@ class ApplicationContainer:
def build_container() -> ApplicationContainer: def build_container() -> ApplicationContainer:
settings = get_settings()
credentials = EncryptedCredentialStore() credentials = EncryptedCredentialStore()
provider_factory = ProviderFactory( provider_factory = ProviderFactory(
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
@@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer:
tools = ToolRegistry() tools = ToolRegistry()
register_builtin_tools(tools) 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.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools") plugins.enable("text-tools")
+16 -20
View File
@@ -256,10 +256,13 @@ class PluginRuntime:
tools: ToolRegistry, tools: ToolRegistry,
host: DeclarativePluginHost | None = None, host: DeclarativePluginHost | None = None,
mcp_bridge: McpBridge | None = None, mcp_bridge: McpBridge | None = None,
*,
allow_unsandboxed_mcp: bool = False,
) -> None: ) -> None:
self.registry = tools self.registry = tools
self.host = host or DeclarativePluginHost() self.host = host or DeclarativePluginHost()
self.mcp = mcp_bridge or McpBridge() self.mcp = mcp_bridge or McpBridge()
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
self._records: dict[str, _PluginRecord] = {} self._records: dict[str, _PluginRecord] = {}
self._lock = threading.RLock() self._lock = threading.RLock()
@@ -346,6 +349,16 @@ class PluginRuntime:
status_code=409, status_code=409,
details={"plugin_id": plugin_id, "permissions": missing_grants}, details={"plugin_id": plugin_id, "permissions": missing_grants},
) )
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) declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)] conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts: if conflicts:
@@ -665,27 +678,10 @@ def _arguments_model_from_schema(
) -> type[BaseModel]: ) -> type[BaseModel]:
if schema.get("type", "object") != "object": if schema.get("type", "object") != "object":
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.") 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():
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) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 # 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段,
# Pydantic 再次拒绝 additionalProperties/patternProperties 接受的合法参数 # 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值
return create_model(model_name, __config__=ConfigDict(extra="allow"), **fields) return create_model(model_name, __config__=ConfigDict(extra="allow"))
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
+27 -2
View File
@@ -509,13 +509,38 @@ def test_mcp_argument_model_preserves_json_schema_additional_properties() -> Non
"mcp-fixture.dynamic", "mcp-fixture.dynamic",
{ {
"type": "object", "type": "object",
"properties": {"model_dump": {"type": "string"}},
"required": ["model_dump"],
"additionalProperties": {"type": "string"}, "additionalProperties": {"type": "string"},
}, },
) )
arguments = arguments_model.model_validate({"dynamic_key": "value"}) arguments = arguments_model.model_validate(
{"model_dump": "method name remains data", "dynamic-key": "value"}
)
assert arguments.model_dump() == {"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: def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None:
@@ -1044,6 +1044,8 @@ Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --fro
面向社区或不可信 Plugin 开放前,Tauri/Rust Host 必须增加平台级沙箱、完整进程树回收、包来源/签名校验,并在首次安装或命令变化时向用户完整展示 executable 和参数、要求明确同意。当前 Python Host 的独立进程、环境裁剪和 Permission 只用于可信开发联调,不能替代这些生产安全门槛。 面向社区或不可信 Plugin 开放前,Tauri/Rust Host 必须增加平台级沙箱、完整进程树回收、包来源/签名校验,并在首次安装或命令变化时向用户完整展示 executable 和参数、要求明确同意。当前 Python Host 的独立进程、环境裁剪和 Permission 只用于可信开发联调,不能替代这些生产安全门槛。
在该门槛完成前,后端仅允许 `APP_ENVIRONMENT=development` 启动未沙箱化 MCP Host;生产环境统一返回 `MCP_TRUST_APPROVAL_REQUIRED`。Python `uvx` Server 在开发模式首次运行可能联网解析依赖,生产版本必须在安装/更新阶段预取并验证固定版本,正常运行阶段只使用已经准备好的环境。
### 12.5 MCP Bridge ### 12.5 MCP Bridge
MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。 MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。
@@ -2327,7 +2329,7 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 91 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
第二阶段在既有 Contract 上接入: 第二阶段在既有 Contract 上接入:
@@ -176,7 +176,7 @@ RunCancelled
## 当前实现状态 ## 当前实现状态
更新至 2026-09-01:后端 91 项回归测试通过。 更新至 2026-09-01:后端 92 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 - Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
@@ -437,11 +437,13 @@ backend:
transport: stdio transport: stdio
command: uvx command: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp] args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 5 startup_timeout_seconds: 60
tool_timeout_seconds: 30 tool_timeout_seconds: 30
``` ```
命令通过参数数组直接启动,不经过 Shell。带路径的 executable 必须位于 Plugin 包内;PATH 中的命令可以按名称引用。Python 包形式的 MCP 推荐使用固定版本的 `uvx --isolated --from`,但 `uvx` 只隔离依赖而不是文件/网络/系统调用安全沙箱,非 Python Server 不强制使用。子进程只继承运行所需的系统环境变量,不继承 `OPENAI_API_KEY``APP_DB_PATH`、Vault 路径等宿主状态。Secret 注入留给阶段 D 的专用引用接口。 命令通过参数数组直接启动,不经过 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` 远端 Tool 的可选项目权限放在 MCP `_meta`
@@ -632,6 +634,7 @@ MCP_CAPABILITY_UNSUPPORTED
MCP_TOOL_SCHEMA_INVALID MCP_TOOL_SCHEMA_INVALID
MCP_TOOL_CALL_FAILED MCP_TOOL_CALL_FAILED
MCP_TOOL_RESULT_TOO_LARGE MCP_TOOL_RESULT_TOO_LARGE
MCP_TRUST_APPROVAL_REQUIRED
PLUGIN_COMMAND_NOT_FOUND PLUGIN_COMMAND_NOT_FOUND
PLUGIN_COMMAND_CONTEXT_INVALID PLUGIN_COMMAND_CONTEXT_INVALID
PLUGIN_SETTINGS_SCHEMA_INVALID PLUGIN_SETTINGS_SCHEMA_INVALID
@@ -2,7 +2,7 @@
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 91 项测试通过。 > 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。
## 当前实现 ## 当前实现
@@ -3,7 +3,7 @@
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 91 项测试通过。 > 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。
## 当前实现 ## 当前实现
@@ -69,7 +69,7 @@ backend:
transport: stdio transport: stdio
command: uvx command: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp] args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 5 startup_timeout_seconds: 60
tool_timeout_seconds: 30 tool_timeout_seconds: 30
``` ```
@@ -160,7 +160,7 @@ MCP Tool
当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。 当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。
`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。首次解析尚未缓存的包可能访问包索引;生产清单必须固定来源和版本,安装/更新阶段与运行阶段分离 `uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。当前开发模式下,首次 `enable` 尚未缓存的包可能访问包索引,因此示例使用 60 秒启动上限;生产实现不得依赖该行为,必须在用户确认后的安装/更新阶段预取和验证固定版本,运行阶段只启动已准备好的环境
已经执行的保护: 已经执行的保护:
@@ -185,6 +185,8 @@ MCP Tool
在这些门槛完成前,当前 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/) 文档。 在这些门槛完成前,当前 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 ## 7. Host API
```http ```http
@@ -187,12 +187,12 @@ pnpm build
```text ```text
pnpm build passed pnpm build passed
pnpm test 27 passed pnpm test 27 passed
uv run pytest 91 passed uv run pytest 92 passed
preview smoke HTTP 200 preview smoke HTTP 200
git diff --check passed 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 权限警告,不影响 91 项测试结果,也不涉及产品代码。 当前前端使用 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 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -104,4 +104,4 @@ pnpm build
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 91 项测试、前端 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、公共接口和后端开发文档。 > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 91 项测试通过。 > 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
## 1. 审阅结论 ## 1. 审阅结论