Files
NotesAgentic/docs/development/独立MCP-Server配置中心开发说明.md
T
admin 9b8b10cdb1 fix(mcp): 修复配置导入、凭据管理与协议边界
修复生命周期锁阻塞事件循环、旧连接回调误停新连接及超时契约不一致。

补齐 MCP JSON 兼容导入、密钥拆分与失败重试,修复 Header 大小写草稿丢失,迁移大小写敏感的环境变量凭据。

在 SSE 行拼接前限制缓冲大小,增加并发、迁移和流式输入回归测试;忽略本机 MCP 数据及 server.json/servers.json。

验证:后端 185 项、前端 54 项测试通过,前端生产构建、相关文件 Ruff 与暂存差异检查通过。
2026-09-03 22:38:35 +08:00

13 KiB
Raw Permalink Blame History

独立 MCP Server 配置中心开发说明

更新日期:2026-09-03。本文记录第二阶段 C.1 的完整实现;独立 MCP Server Registry 与 Plugin 自带 MCP Host 是两个并列入口。

1. 已实现范围

  • 独立 Server 的创建、读取、版本化编辑、删除和 Tool 摘要查询;
  • stdio、Streamable HTTP 和旧版 HTTP+SSE 三种 Transport
  • stdio 可执行文件、参数、普通/加密环境变量,以及 HTTP URL、普通/加密 Header
  • 配置摘要确认、连接测试、启停、异常状态与最近一次测试结果;
  • MCP initialize、tools/listtools/call、取消与动态 Tool 注册,名称为 mcp.{server_id}.{tool}
  • Streamable HTTP Session、协议版本 Header、JSON/SSE POST 响应、可选 GET 事件流和 Last-Event-ID 重连;
  • 旧 HTTP+SSE 的 endpoint 事件与消息 POST,并强制消息地址和配置地址同源;
  • 前端表单/JSON 双模式、三种模板、高风险变更确认及请求期 Secret 输入。

Streamable HTTP 按 MCP 当前规范实现;SSE 仅用于兼容旧 Server,不应作为新部署首选。

2. 配置、版本与 Secret

普通配置原子写入 APP_DATA_DIR/mcp/servers.json。更新请求必须携带读取到的 version;版本过期返回 409 MCP_SERVER_VERSION_CONFLICT,避免多个页面互相覆盖。改变 Transport、命令、URL、Header、环境变量或权限后,旧授权和测试结果立即失效。

backend/data/mcp/ 是本机运行数据,包含连接配置、授权状态和第三方进程工作目录,不属于团队共享配置。.gitignore 忽略整个目录以及 server.jsonservers.json 文件名;不得强制添加到 Git。提交前检查暂存文件清单,不要将本地密钥、连接配置或运行数据推送到远程。

Secret 使用带类型和键名哈希的 mcp.* 内部 ID 写入 Fernet 凭据存储。查询 API 只返回环境变量或 Header 是否配置,不返回明文。删除 Server 或移除 Secret 键会同步清理密文。前端密码框提交后立即清空;用户主动粘贴到 JSON 的密钥仅在当前编辑会话中暂存,解析后从 JSON 中移除,不写入 localStorage、普通配置或日志。

环境变量的凭据 ID 使用区分大小写的 v2 名称规则,Header ID 保持大小写不敏感。更新配置按凭据 ID 的差集删除密文,因此 Authorization 改为 authorization 不会丢失认证信息。启动时对无歧义的旧环境变量凭据原子迁移密文,不覆盖新 ID 已有的值;若旧配置把 TOKENtoken 合并存到了同一个 ID,无法推断原来的两个值,会保留旧密文、停用连接并要求重新录入和测试。删除服务器时也会清理这些保留的旧密文。

2.1 JSON 导入与 API Key 填写

前端支持 NotesAgent 完整/精简配置、单个 command / args / env 配置,以及只含一个服务器的 mcpServers 包装。JSON 与表单之间切换会补齐数组、对象及超时默认值,并校验字段类型。批量导入暂不支持;后端配置接口仍只接收 NotesAgent DTO,兼容转换发生在前端。

可以先声明 secret_environment_keys,保存后在服务器卡片的密码框填写密钥;也可以把密钥放进 JSON 的 environment 或通用配置的 env。前端会将已声明的敏感变量,以及名称含 API Key、Token、Secret、Password、Authorization、Cookie、Credential 的常见字段拆出:普通配置请求只包含键名,密钥另经 Secret API 加密保存。其他敏感字段必须显式声明,不能只依赖名称识别;命令与参数中不要携带密钥。

例如 MiniMax 的输入结构如下,占位值需在自己的本地页面替换,不要把真实密钥贴进聊天或提交到 Git:

{
  "name": "MiniMax Coding Plan",
  "command": "uvx",
  "args": ["--index-url", "https://pypi.tuna.tsinghua.edu.cn/simple", "--with", "mcp<2", "minimax-coding-plan-mcp", "-y"],
  "environment": {
    "MINIMAX_API_HOST": "https://api.minimaxi.com",
    "MINIMAX_API_KEY": "<在本地填入新密钥>"
  },
  "secret_environment_keys": ["MINIMAX_API_KEY"],
  "startup_timeout_seconds": 120,
  "tool_timeout_seconds": 300
}

旧版前端将 environment.MINIMAX_API_KEYsecret_environment_keys 原样一起发送,触发后端“普通与敏感变量不可同名”的校验。这是配置保存失败,不是模型服务返回的鉴权失败。现在在前端拆分两类请求,后端仍保留互斥校验。

导入兼容规则:env 转为 environmenttimeout 作为启动超时;sse_read_timeout 作为工具等待上限,不保留其原客户端 SSE 读取超时语义。启动超时范围为 1–120 秒,工具超时为 1–300 秒。URL 必须是纯地址,不能使用 Markdown 链接,JSON 中不能包含 \_ 这样的非法转义。

另一个已修复的失败原因是运行时适配层复用了 PluginBackend 的整数超时与 60 秒启动上限,导致合法的 120 秒或小数超时配置在保存返回、读取或测试时失败。独立 Server 现在使用专门的 Bridge 适配模型,保留自己的浮点超时范围,不改变 Plugin 清单原有约束。已有的 120 秒记录可直接读取,无需删库重建。

配置保存成功但后续 Secret 写入失败时,窗口保留服务器 ID、新版本和未写入的密钥。点击保存会更新同一服务器并重试,不重复创建记录;取消会清除未保存密钥,已经保存的服务器和凭据不会回滚。错误信息显示在配置窗口内。保存配置不会自动运行第三方进程,仍需确认、测试和启用。

暂存的 Header Secret 与已保存凭据使用一致的大小写规则:将 Authorization 改为 authorization 不会丢弃尚未保存的值,提交时采用当前声明名。重新输入同一 Header 的值会覆盖旧草稿;真正删除声明才清除草稿。环境变量仍区分大小写,不会把 TOKEN 的草稿转交给 token

跨 Registry 与凭据存储的删除以“失败后可重试”为顺序约束:先原子清理密文,再提交新版本或删除 Registry 记录。凭据存储失败时保留原版本和 Server 记录,避免出现返回 500 但配置已提交、版本无法重试或密文失去清理入口的状态。

写入或删除 Secret 会先停用正在运行的连接、注销动态 Tool,并撤销当前配置的测试通过状态;必须使用新凭据重新测试后才能启用。这样页面展示的凭据状态不会与运行中进程实际持有的旧凭据不一致。

3. 启用与运行时规则

一次连接按以下顺序执行:

  1. 用户检查服务端生成的连接摘要并确认当前摘要;
  2. 后端临时连接,完成 initialize 和 tools/list 后关闭连接;
  3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 Tool;
  4. 停用、删除、超时或异常退出会注销 Tool 并关闭连接。

运行期连接异常或 Tool 列表变化时,Registry 会在同一生命周期临界区内标记不可用、注销 Tool,并从 Bridge 移除 Host。每次启动分配独立的连接代次;失败回调取得锁后先核对代次,旧连接延迟到达的回调不能停用新连接。停用、测试结束及关闭服务时撤销对应代次。

所有独立 MCP API 都通过工作线程执行,包括新增、确认授权和读取接口。虽然部分操作不直接访问网络,但仍可能等待正在测试或启动的连接持有的锁,不能在 FastAPI 事件循环上同步等待。

HTTP Header 中 HostContent-TypeMCP-Session-Id 等协议保留项不可由配置覆盖。URL 不允许内嵌凭据或 Fragment。旧 SSE 返回的 POST endpoint 必须与初始 URL 同源,防止认证 Header 被转发到其他站点。

启动与 Tool 请求超时会同时应用于业务等待和底层 HTTP 请求;旧 SSE 的 endpoint 等待也使用启动超时。非主动结束的旧 SSE 事件流视为 Host 不可用,宿主随后注销 Tool。注册表加载时逐条校验 Server ID、配置字段、Transport 组合和摘要格式,损坏记录统一返回 MCP_REGISTRY_INVALID

两种 HTTP Transport 共用有界 SSE 行解析器:按响应字节块检查未完成行及当前事件的累计大小,再扩展缓冲区,不依赖 iter_lines() 先缓存完整行。持续无换行输入也会及时触发上限;解析兼容跨块 UTF-8、首行 BOM、LF/CR/CRLF、多行 data 和事件间计数重置。tests/test_mcp_sse_limits.py 覆盖这些边界,防止仅在完整行生成后检查大小。

stdio 命令不经过 Shell,管道、重定向和命令拼接不会被解释。Windows 使用新进程组并通过 taskkill /T 回收子树;POSIX 使用独立 session/process group 并向进程组发信号。Python 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。

uvx 模板使用 --isolated、明确的 --from 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 403 MCP_SANDBOX_REQUIRED;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。

4. 接口

GET    /api/mcp/servers
POST   /api/mcp/servers
GET    /api/mcp/servers/{server_id}
PUT    /api/mcp/servers/{server_id}
DELETE /api/mcp/servers/{server_id}
GET    /api/mcp/servers/{server_id}/tools
POST   /api/mcp/servers/{server_id}/trust
POST   /api/mcp/servers/{server_id}/test
POST   /api/mcp/servers/{server_id}/enable
POST   /api/mcp/servers/{server_id}/disable
PUT    /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header
DELETE /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header

完整字段、状态和错误码见《第二阶段接口契约-开发版》。协议实现参考 MCP 官方的 TransportsLifecycle

5. 验证

cd backend
uv run pytest -q tests/test_mcp_registry.py tests/test_extension_core.py

cd ../frontend
npm run type-check
npm test
npm run build

后端测试使用无需网络或真实密钥的 stdio Fixture,以及 httpx.MockTransport 驱动的确定性 HTTP/SSE Server Fixture。覆盖摘要授权、Secret 不回显、版本冲突、生产门禁、重启恢复、Streamable HTTP Session/Header/工具调用及旧 SSE 同源校验;新增覆盖旧回调隔离、路由线程卸载、凭据大小写差异与旧密文迁移。前端覆盖模板切换、JSON 默认值与格式兼容、明文拆分、模式切换、部分保存失败重试、取消清理、测试失败和删除确认。此处的 MiniMax 配置转换测试使用假密钥,不等同于真实 MiniMax 网络调用验证。

6. 后续边界

本轮 P1/P2 修复验收

审阅问题 修复方式 回归验证
P1:新增或授权等待生命周期锁时阻塞事件循环 独立 MCP 路由统一交给工作线程 test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive:分别阻塞 create/trust,在锁释放前仍能执行健康检查
P2:旧失败回调误停新连接 回调在锁内核对连接代次 test_old_failure_callback_cannot_stop_replacement_host:旧回调排队期间重启连接,释放锁后新连接仍可用,当前代次的失败仍正确停用
P2:精简 JSON 切换模式或编辑保存报错 运行时校验、默认值补全、统一转换 configuration.spec.tsMcpServersView.spec.ts:精简 JSON、编辑版本、模式切换和 Secret 保存重试
P2Header 大小写改名删除凭据 按规范化凭据 ID 而非原始键名计算差集 test_header_case_only_rename_preserves_secret
P2:大小写不同的环境变量覆盖同一凭据 区分大小写的 v2 ID,带迁移标记的旧密文迁移 test_environment_secrets_are_case_sensitive_and_delete_independently 及 legacy migration 测试,包含删除后不复活旧密钥
P1:SSE 无换行输入在大小校验前无限缓冲 在行拼接前校验字节数与事件累计大小 test_mcp_sse_limits.py,包括小块持续输入和跨块换行
P2:Header 大小写改名丢失未保存密钥 草稿使用规范化名称匹配,并重新绑定当前声明名 configuration.spec.ts 与页面保存回归测试

这些修复不放宽 stdio 的 JSON-RPC 校验。第三方程序向 stdout 打印普通日志造成的握手失败,应由服务端调整输出或使用不打印日志的启动入口处理。

后续工作

  • 增加真实第三方 Server 的兼容矩阵;确定性 Fixture 只能证明宿主协议行为,不能代表所有实现兼容;
  • C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract;
  • 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。