# 架构与写回模型 > 这份文档回答的是「它到底写了什么、写到哪、凭什么不把宿主编译坏」。 > 结论都指向源码,可逐条核对: > `plugin-src/host/{index,store,validate,adapter}.mjs` + `plugin-src/client/client.js`。 ## 1. 两层文件、两条写回路径 ``` ┌─────────────────────────────────────────────┐ 面板(浏览器) │ 设置 → 侧栏「MCP 客户端」 │ └───────────────┬─────────────────────────────┘ │ RPC(POST /api/mcp-client-manager) ┌───────────────▼─────────────────────────────┐ 宿主半侧 │ index.mjs:RPC 方法表 + 写回编排 │ │ ├── 受管行:store.mjs 渲染 → 自有层文件 │ │ └── 手写行:adapter.mjs → 官方 ctx 服务 │ └───────┬───────────────────────┬─────────────┘ │ │ 自有层(本插件独占) ▼ ▼ 官方通道(我们只发指令) $DSH_HOME/cordis.patch.yml ctx.configEditor.edit() / ctx.pluginManager.setPluginEnabled() $DSH_HOME/mcp-manager/servers.json │ ▼ profiles//cordis.patch.yml(官方落点) ``` | 层 | 文件 | 谁写 | 说明 | |---|---|---|---| | 真源 | `$DSH_HOME/mcp-manager/servers.json` | 本插件独占(`0600`) | `{version, forProfile, rev, servers[]}`;`rev` = 渲染正文的 sha256 | | 生成物 | `$DSH_HOME/cordis.patch.yml` | 本插件独占(`0600`,**全量重写**) | 三行身份头 + `managed-by` 标记 + `# rev: ` + 一个 `- insert:` 块 | | 手写层 | `profiles//cordis.patch.yml` | **永不直接改写** | 改写/启停只经官方服务;官方服务自己会把覆盖行写进这个文件 | **为什么自有层不解析 YAML**:我们只写自己生成的内容 —— 渲染是**纯函数、确定性**(同输入必同输出, `store.test.mjs` 直接单测),因此不需要 YAML 解析器,也不会碰坏用户的注释或缩进。 **文件写坏 = 下次启动起不来**,这是本设计要避免的头号事故。 **为什么手写行走官方通道**:官方配置编辑器带原子写、失败回滚、被更高层覆盖则拒写; 自己写一遍等于重实现这三件事,且一旦语义不一致就会与宿主互相打架。 ## 2. RPC 接口(信封是硬性的) 客户端(`connection` 服务的 rpc 解析器)是唯一裁判: ```jsonc // 请求 { "type": "client-request", "rpcId": "…", "method": "mcp-client-manager", "payload": { "method": "list", "payload": {} } } // 成功 { "type": "server-response", "rpcId": "…", "result": { "ok": true, "value": { … } } } // 失败 { "type": "server-response", "rpcId": "…", "result": { "ok": false, "error": { "code": "…", "message": "…", "details": {} } } } ``` | 方法 | 载荷 | 返回(`value`) | 说明 | |---|---|---|---| | `state` | — | `{profile, paths, adapters, inventory, drift, toolsProbe, counts}` | 自检:路径(**符号化**)、服务就绪、漂移、清单规模 | | `list` | — | `{profile, paths, servers[], revision, drift, adapters, toolsProbe, writeEnabled, inventory}` | 受管行 + 手写行的合并视图(含状态与工具数) | | `save` | `{id?, serverName, transport, url?/command?/args?/env?/cwd?, failOnStartupError?, expectedRev?}` | `{ok, applied:'live', revision, created?, row}` | 有 `id` = 改写,无 `id` = 新增;手写行改写走官方通道 | | `remove` | `{id, expectedRev?}` | `{ok, applied, revision, remaining}` | **只对受管行**;手写行返回 `external-row-readonly` | | `revert` | `{id, expectedRev?}` | `{ok, applied, revision, row}` | 撤销手写行的面板覆盖(还原 `insert` 原值) | | `heal` | `{mode: 'overwrite' \| 'forget'}` | `{ok, healed, revision, overwrittenForeign?}` / `{ok, healed:'forget', droppedServers}` | 漂移逃生口:以清单为准重建 / 以文件为准清空 | | `setEnabled` | `{id, enabled, expectedRev?}` | `{ok, applied, revision, row?/change?}` | 受管行改自有层;手写行走官方 `pluginManager` | **乐观锁**:除 `state` / `list` / `heal` 外,带 `expectedRev` 的方法在不匹配时返回 `stale-revision`, 避免两个窗口互相静默覆盖。 ### 错误码(`code` 即诊断) | 码 | 含义 | 处置 | |---|---|---| | `invalid-input` | 字段校验未过(`details.errors` 逐条给 code/field/message) | 按提示改 | | `duplicate-serverName` / `id-conflict` | serverName 重名 / 行 id 被多层声明 | 换名,或先删掉另一处声明 | | `nothing-to-save` | 手写行改写时所有凭据族字段都留空 | 想改就必须整段重填 | | `external-row-readonly` | 手写行不可删(或官方已锁该行开关) | 改用禁用 | | `not-applicable` | 对受管行调用 `revert` | 直接保存或删除即可 | | `drift-detected` | 自有层被外部改动,面板已转只读 | 用两个逃生口之一处置 | | `stale-revision` | 页面拿的 revision 过期 | 刷新后重试 | | `adapter-unavailable` | 宿主缺 `configEditor` / `pluginManager` | 该 profile 不支持手写行改写 | | `row-vanished` / `row-not-editable` | 行刚被外部移除 / 配置是表达式 | 手工处理 | | `write-failed` | 写自有层失败(清单已回滚) | 看磁盘权限与宿主日志 | | `bad-request` / `unknown-method` / `handler-failed` | 信封不合法 / 方法不存在 / 处理器异常 | 客户端 bug 或版本不匹配 | ## 3. 漂移检测(自有层的自保) 生成物里带 `# rev: `,正文的哈希必须对得上。判定顺序(`store.mjs:checkDrift`): | 现象 | `reason` | 面板 | |---|---|---| | 文件不存在且清单为空 | `absent` | 正常可写 | | 文件不存在但清单有行 | `missing-file` | 只读 | | 文件里没有 `managed-by` 标记 | `foreign-file` | 只读(有人用别的工具写了这个文件) | | 有标记但没有 rev 行 | `no-rev` | 只读 | | rev 与正文哈希不符 | `content-changed` | 只读(含换行符变化) | | 全部吻合 | `ok` | 可写 | **只读 ≠ 死锁**:两个逃生口 —— 「以清单为准重建」(覆盖文件)与「以文件为准清空清单」 (放弃受管行,让文件回归自由)。**先想清楚哪边是真源,再点。** ## 4. 手写行被改写后会发生什么(最容易踩的一条) 官方配置编辑器**只认顶层 `- id:` 行,且排除 `insert` 行** (DSH 源码 `packages/boot/config-editor/src/index.ts:117-128`)。而手写行通常嵌在 `- insert: [...]` 里,于是: 1. **首次**改写该行 → 在 `profiles//cordis.patch.yml` **末尾追加**一条顶层覆盖行 (`- id: … / name: … / config: …`);原 `insert` 行**一个字节都不动**。 2. 同文件内靠后的行优先 → **生效的是覆盖行**。 3. **之后**再改同一行 → 命中那条覆盖行并**原地更新**,不会越堆越多。 4. 面板给该行标 `已覆盖` 并提供「**撤销覆盖**」——它用官方语义(`next === inherited` 时移除覆盖行) 把值还原成 `insert` 行的原值。 **后果提醒**:面板没改过的行,直接编辑 `insert` 行立刻生效;**已被面板覆盖的行,改 `insert` 行不生效** (被覆盖行压住),必须先点「撤销覆盖」。另外,「撤销覆盖」只删 `config`,不会动同一行上的 `disabled` 等其他键。 ## 5. 安全口径(数据不出宿主) 1. **`url` 永不回传**:只给 `协议//主机/…`(`adapter.mjs:maskUrl`),不含路径、查询串、key 片段,也不透露长度。 2. **stdio 的 `env` 取值永不回传**:只回传键名与"已设置"(`toDto` 只放 `envKeys` / `envSet`)。 3. **凭据族字段留空 = 保留原值**:`url` / `command` / `env` 未改就不重写,避免"补齐表单"把密钥擦掉。 4. **路径符号化**:回传给浏览器的一切路径都按 `$DSH_HOME` 前缀裁剪,页面里不出现含用户名的绝对路径。 5. `servers.json` 与生成物均按 `0600` 原子写入(临时文件 + rename;Windows 下 rename 被占用时退避重试)。 > ⚠️ 已知的**同一台机器上的旁路**:`mcp-client` 的 Config 没有 `role('secret')`, > 因此经 DSH 官方设置页编辑同一行时,密钥仍会到达浏览器。这与本插件无关,但确实存在。