首页
/ OpenHands Canvas:MCP 服务器设置的安全持久化规范(specs/mcp-settings)解析

OpenHands Canvas:MCP 服务器设置的安全持久化规范(specs/mcp-settings)解析

2026-09-04 15:01:28作者:羿妍玫Ivan

OpenHands 的 Web 前端(Canvas)中,MCP(Model Context Protocol)服务器的增删改查并不是简单地"提交整个设置对象",而是围绕 mcp_config 设置表做一组精心设计的稀疏合并补丁(sparse merge patch)。本文以 specs/mcp-settings.md 中的三条行为规范(MCP-001/002/003)为主线,逐条拆解其背后的动机,并结合 设置服务补丁构建工具 与对应的 mutation hooks,说明这套"稀疏变更 + 密钥保留 + 稳定标识"机制是如何在源码中落地的。读完本文,你将理解为什么在浏览器端编辑 MCP 设置时,既不会覆盖兄弟服务器的凭据,也不会把 ********** 脱敏占位符当成真实数据写回后端。

一、规范文档概览:三条 Spec 解决什么问题

specs/mcp-settings.md 是三条带勾选状态的规格条目,它们共同约束"前端对 MCP 服务器设置的每一次变更请求":

  • MCP-001 稀疏变更保留兄弟服务器(Sparse mutations preserve sibling servers)
    • 新增、更新或删除某一台 MCP 服务器时,必须恰好发出一个专门的 MCP 设置请求,且请求体中只包含受影响的服务器
    • MCP 变更绝不能以脱敏(redacted)或加密(encrypted)的设置快照作为变更基座
    • 未触碰的兄弟服务器及其凭据,必须能够安然活过新增、更新、删除、并发不同键的更新以及失败的变更等所有场景。
  • MCP-002 密钥补丁保留用户意图(Secret patches preserve user intent)
    • 省略一个未修改的密钥,其存储值必须被保留;
    • 提供一个密钥,则替换其存储值;
    • 显式清空一个受支持的密钥字段,必须发送 null
    • 仅用于展示的脱敏哨兵值永远不允许作为变更数据发出。
  • MCP-003 设置表键是稳定的 MCP 标识(Settings map keys are stable MCP identities)
    • 渲染顺序和传输方式分组不能改变持久化身份;
    • 删除必须直接定位设置表键,而不是靠匹配 URL、命令或参数;
    • 重命名必须使用一次原子化的 map 补丁,拒绝键冲突,并拒绝会导致凭据丢失的"隐藏密钥重命名"。

这三条规范针对的核心矛盾是:MCP 编辑器展示给用户的是脱敏后的设置(密钥显示为 **********),而后端存储的是真实凭据(可能是密文)。如果前端把"看到的"整体提交回去,未修改的密钥就会被占位符覆盖;如果前端拿本地缓存的加密快照去重建整个 mcp_config,又会在并发编辑下把兄弟服务器的新值冲掉。规范把这两个风险分别用 MCP-001 和 MCP-002 封死,用 MCP-003 则保证身份稳定性。

二、MCP-001:一次只动一台服务器,且绝不用脱敏/加密快照做基座

2.1 请求入口:三个 mutation hook 各自只发一个专用请求

MCP 设置页面的三个写操作分别由三个 React Query mutation 承载,且源码中都带有 // @spec MCP-001 — Sparse mutations preserve sibling servers 的追踪注释:

以删除为例,mutation 函数体只做一件事——按设置表键调用服务层,不读取也不重建任何其他目录条目:

// src/hooks/mutation/use-delete-mcp-server.ts
mutationFn: async (target: MCPServerConfig): Promise<void> => {
  await SettingsService.deleteMcpServer(target.id);
}

更新流程同样以 serverId(即设置表键)为唯一定位方式:先取出该键对应的 previous 条目,然后走"重命名"或"原地补丁"两个分支之一(见第四节)。整个过程中没有任何"把整个 mcp_config 快照发回后端"的调用。

2.2 服务层:四种专用端点 + 云端降级路径

SettingsService 为本地 agent-server 暴露了四个细粒度方法,每个方法对应规范中"恰好一个专门请求"的要求:

方法 本地后端行为 云端后端降级行为
createMcpServer(key, server) SettingsClient.createMcpServer(key, server) 专用端点 折叠为 patchMcpConfig({ [key]: server })
patchMcpServer(key, patch) SettingsClient.patchMcpServer(key, patch) 专用端点 折叠为 patchMcpConfig({ [key]: patch })
deleteMcpServer(key) SettingsClient.deleteMcpServer(key) 专用端点 折叠为 patchMcpConfig({ [key]: null })
patchMcpConfig(patch) PATCH /api/settingsagent_settings_diff: { mcp_config: patch } cloudCompatibleMcpConfig 转换后走 saveCloudSettings

patchMcpConfig 上的注释直接呼应了规范:

/**
 * Apply one name-keyed MCP merge patch in exactly one request. The server
 * owns the stored catalog and secret preservation; Canvas never rebuilds
 * the catalog from redacted display settings.
 */

也就是说:服务器拥有存储目录和密钥保留的职责,前端(Canvas)永远不基于脱敏展示设置重建目录——这正是 MCP-001 第二条"不得以脱敏或加密快照作为变更基座"的源码级表达。

2.3 凭据如何在展示侧"圆"回来而不污染持久化

虽然持久化从不使用脱敏/加密快照,但连通性测试需要真实凭据:编辑器里未改动的密钥显示为 **********,如果直接拿它去连服务器必然失败。substituteRedactedMcpCredentials 解决了这个问题:

  1. 探测配置中是否存在脱敏叶子(hasRedactedValue(server.env)hasRedactedMcpSecretLeaf(server.auth) 等,见 src/utils/mcp-config.tsREDACTED_MCP_SECRET_VALUE = "**********" 的定义);
  2. 若有,则以 exposeSecrets: "encrypted" 模式从 API 拉取该服务器的加密存储值SettingsService.fetchSettingsFromApi("encrypted"));
  3. substituteRedactedLeaves 递归地只把 ********** 占位符替换为对应的加密叶子,其余结构原样保留。

该函数的注释明确划定了边界:

/**
 * ... Persistence never calls this helper; sparse settings patches omit
 * unchanged secrets.
 */

即这个"占位符回代"只服务于 McpService.testServer 与 OAuth 探测这类只读探测请求,绝不出现在任何持久化补丁里。测试响应在返回前端前还会经 redactMcpTestResponseredactMcpSecrets 反向清洗,保证明文不出现在 UI 中。

三、MCP-002:密钥补丁的四条语义在 buildMcpServerPatch 中的实现

MCP-002 的三条"should/never"规则,对应 src/utils/mcp-config.tsbuildMcpServerPatch(标注 // @spec MCP-002 — Secret patches preserve user intent)及其辅助函数的具体分支。函数头注释同样点题:Redacted values are display-only and are never mutation inputs(脱敏值仅供展示,绝不作为变更输入)。

3.1 省略未变密钥 → 保留存储值

字符串映射(stdio 的 env、header 认证/远程 headers)由 buildStringMapPatch(previous, next) 处理:

for (const [key, value] of Object.entries(next)) {
  if (value !== REDACTED_MCP_SECRET_VALUE) {
    patch[key] = value;
  }
}

值为 ********** 的键直接跳过、不进入补丁——补丁里没有这个键,服务端的 merge patch 语义自然保留存储值。这就是"省略未变密钥则保留"的落地方式:不是前端"记得旧值",而是根本不下发该键。

嵌套结构(OAuth 的 authenticationstate)走 buildRedactionSafeNestedPatch,规则一致:next === REDACTED_MCP_SECRET_VALUEundefined 时返回 undefined(跳过),next === null 时透传 null(显式清空),相等时省略,与之前值不同才写入补丁。

3.2 提供密钥 → 替换存储值

用户在编辑器中输入了新密钥时,next 中该键不再是占位符,buildStringMapPatch 会原样把它写入补丁;对于认证凭据,buildMcpServerPatch!hasRedactedMcpSecretLeaf(edited.auth) 的分支会把整个新凭据(经 withAuthStrategyReplacementDeletes 处理)放入 patch.auth,完成整值替换。

3.3 显式清空 → 发送 null

同一 buildStringMapPatch 的后半段负责"显式删除"语义:

for (const key of Object.keys(previous ?? {})) {
  if (!(key in next)) patch[key] = null;
}

之前存在、现在被用户删掉的键,发送 null;服务端按 merge patch 语义(见 applyMcpServerPatchvalue === null → delete)将其删除。认证整体移除则是更外层的规则:else if (previousRemote?.auth) { patch.auth = null; }——编辑器里没有认证、之前有认证,就显式发 null 清掉。

策略切换(如 api_key 换 bearer)时,withAuthStrategyReplacementDeletes 还会把旧策略残留字段补 null,防止陈旧密钥在切换后"复活"。云端路径的 cloudCompatibleMcpConfig 也做了类似 tombstone 处理:把旧凭据产生的 headers 清掉,避免陈旧密钥跨策略存活。

3.4 脱敏哨兵永不作为变更数据发出

四条防线共同保证 ********** 不出现在任何补丁中:

  • buildStringMapPatch 跳过占位符值;
  • buildRedactionSafeNestedPatch 遇到占位符/undefined 直接返回 undefined
  • OAuth 分支里 hasRedactedMcpSecretLeaf(edited.auth) 为真时不写 patch.auth
  • header 认证策略下,若补丁中出现 null 值头(即试图删单个 header),直接抛出 MCP_HEADER_REMOVAL_ERROR("Removing an individual header from header authentication is not supported yet..."),把不安全的编辑挡在客户端。

3.5 行为验证:对应测试

上述四条语义并非纸面约定,仓库中有对应的行为测试:src/utils/mcp-config.test.ts 覆盖补丁构建(含占位符跳过、null 清空、策略切换 tombstone),src/hooks/mutation/use-add-mcp-server.test.tsx、use-update-mcp-server.test.tsx、use-delete-mcp-server.test.tsx 覆盖三个 hook 的请求形态与并发/兄弟服务器保留场景,src/api/settings-service/settings-service.api.test.ts 则验证服务层端点选择与缓存失效。

四、MCP-003:设置表键即身份——删除、重命名与键分配

4.1 键就是身份,渲染无关

parseMcpConfig 的注释写明:The map key remains the server's persistence identity(map 键保持作为服务器的持久化身份)。解析时无论 SDK 形状是扁平 map 还是 mcpServers 包装,键都原样保留;UI 的列表顺序、按 stdio/远程分组展示都不参与持久化——useDeleteMcpServer 的注释同样强调:The UI server id is the canonical settings map key, so deletion does not read, match, or reconstruct any other catalog entries。删除请求只带 target.id,不与 URL、命令或参数做任何比对。

4.2 重命名:一次原子 map 补丁 + 双重拒绝

useUpdateMcpServer 中,若编辑后的名称归一化后(toMcpServerName(server.name || serverId))不同于原键,则走重命名分支:

  1. 冲突拒绝if (nextKey in currentConfig) throw new Error(\MCP server "${nextKey}" already exists.`)`——拒绝与既有键冲突;
  2. 原子补丁:调用 SettingsService.patchMcpConfig(buildRenameMcpConfigPatch(serverId, nextKey, previous, server)),整个"删旧键 + 建新键"在一次请求中完成;
  3. 隐藏密钥拒绝buildRenameMcpConfigPatch 先检查旧条目里的密钥(stdio 的 env 或远程的 auth/headers)是否含有脱敏叶子:
if (hasRedactedMcpSecretLeaf(previousSecrets)) {
  throw new Error(MCP_RENAME_CREDENTIAL_ERROR);
}

错误文案为 "Replace or clear the stored credential before renaming this MCP server."。原因很直接:前端手里只有 **********,无法把真实凭据原样搬到新键下,若强行重命名,旧键删除后凭据就丢了。所以规范要求在凭据已被替换或清空(前端持有完整值)的前提下才允许重命名。

最终补丁形如 { [oldKey]: null, [newKey]: renamedServer },其中 renamedServerapplyMcpServerPatch(previous, buildMcpServerPatch(previous, edited)) 在本地模拟 merge patch 得到——注意这里模拟的是"服务器视角的合并结果",而不是把脱敏快照发出去。

4.3 新增时的键分配

新增流程(useAddMcpServer)通过 allocateMcpSettingsKey 分配键:优先取服务器名称(经 toMcpServerName 归一化),名称为空则回退到传输类型(sse/shttp/stdio);若名称已占用,则依次尝试 ${base}_1${base}_2……直到找到空位。随后调用 SettingsService.createMcpServer(settingsKey, toCanonicalMcpServer(server))——toCanonicalMcpServer 负责把 UI 形状(type: "shttp")规整为 SDK 形状(transport: "http" 等),空 args/env/headers 一律省略,保持补丁干净。

五、补丁如何被应用:null 删除 + 递归合并的服务端语义

applyMcpServerPatch 在客户端复现了服务端对 MCPServerPatch 的合并规则,是理解整套规范行为的关键参照:

for (const [key, value] of Object.entries(next)) {
  if (value === null) {
    delete merged[key];        // null = 删除该键
  } else if (isRecord(value)) {
    merged[key] = apply(merged[key], value);  // 嵌套对象递归合并
  } else {
    merged[key] = value;       // 其余值直接覆盖
  }
}

即:未出现的键不动(保留兄弟键/未变密钥)、null 删除、对象递归、标量覆盖。MCP-001 的"兄弟服务器存活"、MCP-002 的"省略保留/显式清空",最终都归结到这一条 merge patch 语义上;重命名之所以要"一次原子 map 补丁",也是因为这个语义里"删旧 + 增新"只有在同一次请求内才具备原子性。

六、小结

specs/mcp-settings.md 的三条规范看似简短,实际上精确刻画了一个"脱敏展示层 ↔ 加密存储层"之间安全同步的完整协议:

  • MCP-001 保证写路径永远是"单服务器、单请求"的稀疏变更,脱敏/加密快照只允许出现在只读探测中(mcp-redacted-credentials.ts 的占位符回代机制);
  • MCP-002 保证补丁中"缺省 = 保留、给值 = 替换、null = 清空、********** = 绝不发出"(buildMcpServerPatch 及其辅助函数);
  • MCP-003 保证设置表键是唯一身份,删除按键直删,重命名是一次原子 map 补丁且拒绝冲突与凭据丢失(buildRenameMcpConfigPatchallocateMcpSettingsKey)。

对开发者而言,若要扩展 MCP 设置相关功能(例如新的认证策略或字段),正确姿势是:继续走 SettingsService.patchMcpServer/patchMcpConfig 的专用端点,在 src/utils/mcp-config.ts 中按"占位符跳过、null 清空"的既有模式扩展补丁构建逻辑,并在 src/utils/mcp-config.test.ts 与对应 hook 测试中补齐对四条密钥语义的断言——这正是规范文档中所有条目均标记为 [x] 所对应的验收方式。

登录后查看全文
热门项目推荐
相关项目推荐