@oh-agent/dsh-mcp-manager 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,364 @@
1
+ # @oh-agent/dsh-mcp-manager
2
+
3
+ English · [中文](README.md)
4
+
5
+ Management page for the MCP servers scattered across every local AI development
6
+ tool — one card per configuration file showing what it declares, one switch per
7
+ server that mounts it in DeepSeek Harness, and one probe of what actually
8
+ answers.
9
+
10
+ DeepSeek Harness → **Settings → MCP Manager** (between Skills Manager and
11
+ Market).
12
+
13
+ ## Install
14
+
15
+ Install the bundle from the **Plugin Manager** page in Settings, or point
16
+ `plugin_manager` at the npm package:
17
+
18
+ ```
19
+ plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.3"
20
+ ```
21
+
22
+ An absolute directory works as `target` too, which is how you install a local
23
+ checkout or the `dsh-mcp-manager.tgz` attached to the
24
+ [v0.1.3 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
25
+
26
+ Do not `pnpm add` the package into the profile by hand, and do not edit the
27
+ profile's `package.json` or `cordis.patch.yml`: installing a package is not the
28
+ same as selecting its bundle, so doing it manually leaves the row present but
29
+ never mounted.
30
+
31
+ Either way the bundle adds one settings tab and one agent tool (`mcp_manage`);
32
+ it declares no runtime dependencies, so there is nothing to build and no
33
+ `allowBuilds` step.
34
+
35
+ The page reads other tools' configuration files but never needs write access to
36
+ them for the switch. Nothing leaves the machine — the only outbound traffic is a
37
+ probe you ask for, and only to the server you asked about.
38
+
39
+ ## The problem it solves
40
+
41
+ An MCP server is declared in a different file, under a different key, in a
42
+ different dialect for every tool:
43
+
44
+ | tool | file (Windows) | top-level key |
45
+ | --- | --- | --- |
46
+ | Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
47
+ | Claude Code | `~/.claude.json`, `<proj>/.mcp.json` | `mcpServers` |
48
+ | Cursor | `~/.cursor/mcp.json` | `mcpServers` |
49
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
50
+ | VS Code | `<ws>/.vscode/mcp.json` | `servers` |
51
+ | Roo Code | `…\rooveterinaryinc.roo-cline\settings\cline_mcp_settings.json` | `mcpServers` |
52
+ | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
53
+ | Qwen Code | `~/.qwen/settings.json` | `mcpServers` |
54
+ | Qoder IDE | `%APPDATA%\Qoder\SharedClientCache\mcp.json` | `mcpServers` |
55
+ | OpenCode | `~/.config/opencode/opencode.json` | `mcp` |
56
+ | Trae | `%APPDATA%\Trae\User\mcp.json` | `mcpServers` |
57
+ | Trae CN | `%APPDATA%\Trae CN\User\mcp.json` | `mcpServers` |
58
+ | Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
59
+ | Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
60
+ | Zed | `settings.json` | `context_servers` |
61
+ | Continue | `~/.continue/config.yaml` | `mcpServers` (YAML array) |
62
+ | CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
63
+ | **DeepSeek Harness** | profile composition | `@deepseek-ai/dsh-mcp-client` rows |
64
+
65
+ The differences are not cosmetic and they are where hand-editing goes wrong:
66
+
67
+ - **Remote transport is spelled differently.** Windsurf only accepts
68
+ `serverUrl` — a `url` key is silently ignored. Gemini and Qwen distinguish
69
+ `url` (SSE) from `httpUrl` (streamable HTTP), while Roo spells streamable
70
+ HTTP `streamable-http`.
71
+ - **Timeouts change unit.** Millisecond fields in Gemini/Qwen/DSH, seconds in
72
+ Roo.
73
+ - **Commands differ in shape.** OpenCode takes `command` as an array and `env`
74
+ as `environment`; everything else takes a string plus a separate `args`.
75
+ - **Some files are shared with unrelated state.** `~/.claude.json` also holds
76
+ history and OAuth tokens; `opencode.json` holds providers and plugins. A
77
+ whole-file rewrite destroys them.
78
+
79
+ This plugin reads all of it into one model and writes back only the entry it
80
+ means to change.
81
+
82
+ ### Windows and macOS
83
+
84
+ Nothing is hard-coded to one platform. Each adapter declares its file through a
85
+ resolver that reads the OS at module load:
86
+
87
+ | root | Windows | macOS |
88
+ | --- | --- | --- |
89
+ | `appData()` | `%APPDATA%` | `~/Library/Application Support` |
90
+ | `xdgConfig()` | `~/.config` | `~/.config` |
91
+ | `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
92
+ | `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
93
+ | `HOME`-relative | `~/.codex`, `~/.cursor`, … | identical |
94
+
95
+ Zed is the exception worth naming: on macOS it reads `~/.config/zed`, **not**
96
+ Application Support, so it gets its own resolver. `APPDATA`, `XDG_CONFIG_HOME`
97
+ and `COPILOT_HOME` are honoured when set.
98
+ Project-scope paths are always joined onto the workspace root, so they are
99
+ portable by construction. `scripts/test-paths.mjs` loads the real adapter source
100
+ under a simulated `win32`, `darwin` and `linux` and asserts the resolved
101
+ directory for every client.
102
+
103
+ The file table above is written the way Windows shows it (`%APPDATA%`, `\`); on
104
+ macOS the same rows resolve under `~/Library/Application Support/`. The scanner
105
+ reports the real absolute path it found, so the page always shows the truth for
106
+ the machine it is running on.
107
+
108
+ ## Architecture
109
+
110
+ ```
111
+ client.js (settings page) index.js (host entry)
112
+ │ │
113
+ └──────── /api/dsh-mcp-manager ────┘
114
+ │ │
115
+ scan.js ops.js probe.js
116
+ │ │ │
117
+ adapters.js store.js surgical.js
118
+ │ │ │
119
+ canonical.js ledger+ byte-preserving
120
+ (one model) backups JSON/TOML/YAML
121
+ │
122
+ dsh.js + dsh-patch.js + toggle.js
123
+ ```
124
+
125
+ - **`canonical.js` — the single semantic model.** Every client entry decodes to
126
+ `{name, transport, command, args, env, url, headers, timeoutMs, enabled,
127
+ autoApprove, extra, secrets}`. Capabilities are *not* flattened to the lowest
128
+ common denominator: a field a client cannot express is dropped on write and
129
+ reported as `droppedFields`, never silently invented.
130
+ - **`adapters.js` — the only format-aware layer.** Eighteen clients described as
131
+ data: container key, dialect (which key means url, which type strings map to
132
+ which transport, timeout unit), capabilities, and where the file lives.
133
+ - **`surgical.js` — byte-preserving editors.** Offset-carrying parse trees and
134
+ bottom-up edits, so an entry can be replaced without reformatting the file.
135
+ Comments, trailing commas, CRLF, and unrelated keys survive; unparsable input
136
+ is refused rather than rewritten.
137
+ - **`store.js` — the safety net.** A backup of the pre-write bytes, a
138
+ compare-and-set hash check, and an ownership ledger.
139
+ - **`probe.js` — does it actually work.** A hand-written JSON-RPC client (no
140
+ dependency on `@modelcontextprotocol/sdk`): spawn stdio → `initialize` →
141
+ `notifications/initialized` → `tools/list`; for remote, POST with
142
+ `Accept: application/json, text/event-stream` and follow an SSE response.
143
+ Read-only — it never invokes a tool.
144
+ - **`dsh.js` / `dsh-patch.js` / `toggle.js` — Harness itself as a first-class
145
+ target.** Existing rows are rewritten through `configEditor.edit()` (validated,
146
+ hot-reloaded). A brand-new server has no row to address, so it goes into the
147
+ composition's `- insert:` patch layer, where the Loader picks it up on its own
148
+ — the operation waits for that row and reports `live: true`.
149
+ If the budget expires first it reports `activating: true`, **never** "restart
150
+ required": a patch row is only written where the Loader can see it, so it always
151
+ arrives eventually, and telling the user to restart for a server that is already
152
+ coming up was measured to be wrong here (mounting several servers back to back
153
+ pushed reconciliation past any fixed budget, then all of them appeared live).
154
+ `toggle.js` is the mount switch: a live row goes through
155
+ `pluginManager.setPluginEnabled`, an unmounted one gets the same
156
+ `disabled: true` override item the plugin manager writes, and a server that
157
+ lives only in another tool is first written to the patch layer.
158
+
159
+ Discovery reads `entry.options.name`, not `entry.name`: a live Loader entry
160
+ has no `name` property of its own (verified against a running Host), and
161
+ checking the wrong field silently matches zero rows — which made every mounted
162
+ server look unreconciled and demand a restart that was never required.
163
+
164
+ ## Safety model
165
+
166
+ The write path is built around one rule: **only rewrite what we can prove we
167
+ wrote.**
168
+
169
+ 1. **Ownership ledger.** Every write records
170
+ `{path, serverName, fingerprint}`. A later write of the same name is applied
171
+ only if the definition on disk still matches the fingerprint — the user has
172
+ not edited it in the meantime.
173
+ 2. **A foreign server is never clobbered.** A name already taken by an entry we
174
+ did not write is treated as the user's: the incoming server gets a suffixed
175
+ name instead.
176
+ 3. **Optimistic concurrency.** The caller passes the hash it scanned; a changed
177
+ file returns `409` with the current hash, and the UI offers a re-scan. Silent
178
+ last-write-wins is not possible.
179
+ 4. **Backup before every mutation**, retained per the `backupsToKeep` config,
180
+ restorable from the page.
181
+ 5. **Atomic replace.** Write a temp file, `rename` over the target, then read
182
+ back and re-parse to prove the result is valid.
183
+ 6. **Credentials are masked before they leave the host.** Masking is
184
+ key-driven, not value-driven — a short token (`TOKEN=abc`) is still masked,
185
+ while an indirection (`${FIGMA_API_KEY}`) is shown as-is because it names a
186
+ secret rather than carrying one. It covers every place a literal hides: env,
187
+ headers, unmodelled `extra`, the raw entry copy, and command-line flags
188
+ (`--figma-api-key=figd_…`, which is the documented invocation for several
189
+ servers and is not an `env` entry at all). `reveal` is an explicit,
190
+ confirmed action.
191
+ 7. **Literal credentials are refused in project scope.** Writing a plaintext
192
+ key into a file destined for a repository fails with a hint to use an
193
+ environment reference.
194
+ 8. **A masked definition can never be written back.** The browser only holds
195
+ the redacted copy, so every write resolves the definition from the host-side
196
+ scan; if a mask (`figd••••0000`) still reaches a write path it is refused
197
+ with `error.server.masked` instead of replacing a real credential with
198
+ bullets.
199
+ 9. **Mutation requests need a custom header** (`x-dsh-mcp-manager: 1`) on top
200
+ of the JSON content-type and the loopback-origin check — a cross-site form
201
+ cannot set it.
202
+
203
+ ## The switch mounts into DeepSeek Harness — and nothing else
204
+
205
+ The one control on each row is a switch, and it has exactly one meaning: **is
206
+ this server mounted in DeepSeek Harness?**
207
+
208
+ Turning it on mounts the server here. Turning it off unmounts it. The file the
209
+ definition came from is never opened for writing by the switch — verified by
210
+ hashing every client config before and after a mount/unmount cycle on this
211
+ machine, all unchanged.
212
+
213
+ That restriction is not squeamishness, it is what the formats force. Most clients
214
+ have no enable field at all: Qoder's and Trae's `mcp.json` carry no enable-ish key
215
+ whatsoever. Writing one would be a switch that lies.
216
+
217
+ How the mount works, by case:
218
+
219
+ | the server is | the switch does | takes effect |
220
+ | --- | --- | --- |
221
+ | an `- insert:` item we wrote | deletes the item on off, rewrites it on on | on reconcile; the file returns to its exact previous bytes |
222
+ | already a live `dsh-mcp-client` row | `pluginManager.setPluginEnabled(rowId, …)` | instantly — the row unmounts and its tools leave the tool list |
223
+ | an `- insert:` item the user wrote | appends/removes the same `- id: …` / `disabled: true` override the plugin manager itself writes | on reconcile |
224
+ | declared in another tool only, and switched on | writes a new `- insert:` row | live as soon as the Loader reconciles it (usually 1–4 s); the operation waits up to 8 s and reports `live: true`, else `activating: true` |
225
+ | not mounted anywhere, and switched off | nothing — already true, no dangling override | — |
226
+
227
+ Our own row is removed before the Loader path is tried: the plugin manager can
228
+ dispose a row hot, but it does so by appending a `disabled: true` override beside
229
+ the definition, which would leave both our row and that override in the user's
230
+ composition permanently. Removing the insert item is both the unmount and the
231
+ cleanup.
232
+
233
+ Because the override is a separate item and not a rewrite, and because a row we
234
+ wrote is removed rather than disabled, a full mount → unmount cycle returns
235
+ `cordis.patch.yml` to its exact previous bytes.
236
+
237
+ ### A mount name is not always the display name
238
+
239
+ `@deepseek-ai/dsh-mcp-client` requires `serverName` to match
240
+ `/^[A-Za-z0-9_-]{1,32}$/`. Many real servers do not — Chinese names commonly look
241
+ like `示例服务器 - API 文档`, and an English name like `My Bridge` carries a space.
242
+ Squeezing those through `sanitizeName` alone gives `API` for every Chinese name,
243
+ so a second mount would collide with the first and the switch would flip the wrong
244
+ server.
245
+
246
+ `canonical.js` therefore exports `dshMountName(name)`: sanitize, and if the result
247
+ lost information (CJK characters, spaces, over-length) append a short hash of the
248
+ original — `示例服务器 - API 文档` → `API-0aac0ee2`, `My Bridge` →
249
+ `My-Bridge`. It is pure and total, so the write path, the scan, the toggle
250
+ and the removal all compute the same string from the same input without storing a
251
+ mapping anywhere.
252
+
253
+ Consequences the scan and the page handle:
254
+
255
+ - the node keeps its display name; `mountedAs` carries the row name and `entryId`
256
+ the Loader id, so the switch finds its row under the mapped name;
257
+ - the server appears under the DSH file card with its display name, and the
258
+ original tools' cards are unchanged;
259
+ - two servers that sanitize to the same base still get distinct row names (the
260
+ hash differs), so no server silently replaces another.
261
+
262
+ `scripts/test-mount-name.mjs` pins the mapping, including the collision case and
263
+ the round trip through scan.
264
+
265
+ ## Using it
266
+
267
+ The page is laid out like the skills page it sits next to: a title row with the
268
+ actions, a four-cell summary strip (servers · files · mounted in DSH · literal
269
+ credentials), a filter row, then one bordered card per config file.
270
+
271
+ Each card lists the servers that file declares — headed by the tool name, the
272
+ server count and the path (shortened to `%APPDATA%\…` style, full path in the
273
+ tooltip). A card collapses; while a filter is active every card opens, because a
274
+ match hidden inside a collapsed card reads as "no results". Every row shows the
275
+ transport, the command or URL, how many tools share the definition, its probe
276
+ result, and the mount switch.
277
+
278
+ The filters are a free-text search (name, command, path) plus two selects: by
279
+ tool, and by mount state (mounted / not mounted).
280
+
281
+ Clicking a row opens a dialog rather than expanding it in place — inline
282
+ expansion reflows every row below and made the card look like two stacked cards.
283
+ The dialog lists every file that declares the server, the canonical definition,
284
+ a paste-ready YAML row for a cordis composition, and "copy to…" for pushing the
285
+ definition into another tool.
286
+
287
+ The same operations are exposed to the agent as the `mcp_manage` tool, so the
288
+ page and the agent share one implementation and cannot diverge:
289
+
290
+ ```
291
+ action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
292
+ ```
293
+
294
+ ## Configuration
295
+
296
+ `cordis.patch.yml` row config:
297
+
298
+ | key | default | meaning |
299
+ | --- | --- | --- |
300
+ | `includeProjectScope` | `true` | also scan workspace-level files |
301
+ | `probeTimeoutMs` | `20000` | per-server probe budget |
302
+ | `probeConcurrency` | `4` | parallel probes |
303
+ | `backupsToKeep` | `20` | retained backups per file |
304
+
305
+ State (ledger + backups) lives in `<DSH_HOME>/mcp-manager/`.
306
+
307
+ ## Verified on this machine
308
+
309
+ Eight of eighteen clients installed, 27 servers discovered across Qoder IDE,
310
+ OpenCode, Trae, Trae CN and DSH. Context7 probes OK with 2 tools,
311
+ `playwright_mcp` with 25. A DSH server mounted and unmounted through the page
312
+ leaves `cordis.patch.yml` **byte-identical** to its pre-write hash, with the
313
+ user's own provider, permission and agency entries untouched — and every other
314
+ client configuration file keeps its hash through the same cycle.
315
+
316
+ Twenty-one plaintext credentials were found on disk — most in `env` maps and
317
+ one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
318
+ their literals appears anywhere in the state payload; the page shows only key
319
+ names, and the write path refuses a masked value.
320
+
321
+ Thirteen test suites under `scripts/` cover the editors, the path layer, the write
322
+ path, the DSH adapter, the patch layer, the mount switch, the mount-name mapping
323
+ for non-ASCII server names, redaction and the masked-write guard, plus an
324
+ end-to-end pass that asserts no credential literal reaches the client payload:
325
+
326
+ ```
327
+ node scripts/test-surgical.mjs 17 passing
328
+ node scripts/test-write.mjs 12 passing
329
+ node scripts/test-dsh.mjs 16 passing
330
+ node scripts/test-dsh-patch.mjs 15 passing
331
+ node scripts/test-dsh-e2e.mjs 8 passing
332
+ node scripts/test-toggle.mjs 16 passing
333
+ node scripts/test-redact.mjs 14 passing
334
+ node scripts/test-masked-write.mjs 5 passing
335
+ node scripts/test-client-load.mjs 28 passing
336
+ node scripts/test-paths.mjs 16 passing
337
+ node scripts/test-mount-name.mjs 12 passing
338
+ node scripts/test-locale.mjs en/zh key parity
339
+ node scripts/test-release.mjs 11 passing
340
+ ```
341
+
342
+ ## Known limits
343
+
344
+ - **Install detection is file presence.** An installed tool with no MCP config
345
+ yet is indistinguishable from an absent one until its directory exists.
346
+ - **A newly mounted DSH server relies on the Loader reconciling it.** The
347
+ operation waits up to 8 s for the row and reports `live: true`; if that window
348
+ expires it reports `activating: true` and the page re-reads the state a moment
349
+ later. No restart is ever demanded for a row the Loader can see.
350
+ - **A row we did not write is switched off, not removed.** Deleting a definition
351
+ the user pasted would destroy their work, so such a row gets the same
352
+ `disabled: true` override the plugin manager writes, and stays in the file.
353
+ - **Only DSH is switchable.** The other tools' files are read and written
354
+ explicitly, never toggled — see the section above.
355
+ - **Continue is read-only** (YAML array shape); editing it is planned.
356
+ - **Secrets are not migrated to a vault.** The plugin masks and warns; moving
357
+ credentials into `${env:...}` references stays the user's decision.
358
+ - **The MCP registry is not consulted.** `registry.modelcontextprotocol.io` is
359
+ explicitly preview and not intended for direct host consumption, so server
360
+ discovery is by scanning local files only.
361
+
362
+ ## Licence
363
+
364
+ MIT
package/README.md CHANGED
@@ -1,17 +1,8 @@
1
1
  # @oh-agent/dsh-mcp-manager
2
2
 
3
- [English](#english) · 中文
3
+ **中文** · [English](README.en.md)
4
4
 
5
- 一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器。
6
-
7
- 同一个 MCP 服务器,在 Claude Code 里写在 `~/.claude.json`,在 Cursor 里写在
8
- `~/.cursor/mcp.json`,在 VS Code 里叫 `servers` 而不是 `mcpServers`,在 OpenCode 里
9
- `command` 又变成了数组。这个插件把 18 个工具各自的配置文件读出来,按「文件」分组展示 ——
10
- 每个配置文件一张卡片,点开看它声明了哪些服务器、每个服务器用什么启动命令、哪些字段带着明文令牌。
11
- 每个服务器给一个开关,把它挂进 DeepSeek Harness;再给一次真连探活,看它到底能不能启动、
12
- 手里有哪些工具。
13
-
14
- **开关只作用于 DeepSeek Harness,不改动任何其他工具的配置文件。** 这是整个插件最重要的一条约束。
5
+ 一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来。
15
6
 
16
7
  入口:DeepSeek Harness → **设置 → MCP**(位于「技能管理」和「插件市场」之间)。
17
8
 
@@ -23,76 +14,31 @@
23
14
 
24
15
  **方式二:让 agent 安装**
25
16
 
26
- 直接对 agent 说「安装 @oh-agent/dsh-mcp-manager」,它等价于:
17
+ 直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
27
18
 
28
19
  ```
29
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.1"
20
+ plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.3"
30
21
  ```
31
22
 
32
23
  `target` 也可以填一个绝对目录 —— 本地源码目录,或者
33
- [v0.1.1 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
24
+ [v0.1.3 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
34
25
  `dsh-mcp-manager.tgz` 解出来的目录。
35
26
 
36
27
  **不要**手动 `pnpm add` 进 profile,也不要手改 profile 的 `package.json` 或
37
28
  `cordis.patch.yml`:装了包不等于选中了它的 bundle,手动装的后果是那一行存在但永远不会被挂载。
38
29
 
39
- 安装后增加一个设置页和一个 agent 工具(`mcp_manage`)。它没有任何运行时依赖,不需要构建,
40
- 也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带
41
- Node 24,无需另装)。
42
-
43
- 页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 ——
44
- 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
45
-
46
- 完整中文文档(支持的工具清单、方言差异、安全模型、已知限制)见
47
- [README.zh.md](README.zh.md)。
48
-
49
- ---
50
-
51
- ## English
30
+ 安装后增加一个设置页和一个 agent 工具(`mcp_manage`)。它没有任何运行时依赖,不需要构建,也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带 Node 24,无需另装)。
52
31
 
53
- Management page for the MCP servers scattered across every local AI development
54
- tool — one card per configuration file showing what it declares, one switch per
55
- server that mounts it in DeepSeek Harness, and one probe of what actually
56
- answers.
57
-
58
- DeepSeek Harness → **Settings → MCP Manager** (between Skills Manager and
59
- Market).
60
-
61
- ## Install
62
-
63
- Install the bundle from the **Plugin Manager** page in Settings, or point
64
- `plugin_manager` at the npm package:
65
-
66
- ```
67
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.1"
68
- ```
32
+ 页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 —— 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
69
33
 
70
- An absolute directory works as `target` too, which is how you install a local
71
- checkout or the `dsh-mcp-manager.tgz` attached to the
72
- [v0.1.1 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
34
+ ## 它解决什么问题
73
35
 
74
- Do not `pnpm add` the package into the profile by hand, and do not edit the
75
- profile's `package.json` or `cordis.patch.yml`: installing a package is not the
76
- same as selecting its bundle, so doing it manually leaves the row present but
77
- never mounted.
36
+ 同一个 MCP 服务器,在每个工具里声明在不同的文件、不同的键、不同的方言下:
78
37
 
79
- Either way the bundle adds one settings tab and one agent tool (`mcp_manage`);
80
- it declares no runtime dependencies, so there is nothing to build and no
81
- `allowBuilds` step.
82
-
83
- The page reads other tools' configuration files but never needs write access to
84
- them for the switch. Nothing leaves the machine — the only outbound traffic is a
85
- probe you ask for, and only to the server you asked about.
86
-
87
- ## The problem it solves
88
-
89
- An MCP server is declared in a different file, under a different key, in a
90
- different dialect for every tool:
91
-
92
- | tool | file (Windows) | top-level key |
38
+ | 工具 | 文件(Windows) | 顶层键 |
93
39
  | --- | --- | --- |
94
40
  | Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
95
- | Claude Code | `~/.claude.json`, `<proj>/.mcp.json` | `mcpServers` |
41
+ | Claude Code | `~/.claude.json`、`<proj>/.mcp.json` | `mcpServers` |
96
42
  | Cursor | `~/.cursor/mcp.json` | `mcpServers` |
97
43
  | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
98
44
  | VS Code | `<ws>/.vscode/mcp.json` | `servers` |
@@ -106,57 +52,39 @@ different dialect for every tool:
106
52
  | Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
107
53
  | Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
108
54
  | Zed | `settings.json` | `context_servers` |
109
- | Continue | `~/.continue/config.yaml` | `mcpServers` (YAML array) |
55
+ | Continue | `~/.continue/config.yaml` | `mcpServers`(YAML 数组) |
110
56
  | CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
111
- | **DeepSeek Harness** | profile composition | `@deepseek-ai/dsh-mcp-client` rows |
57
+ | **DeepSeek Harness** | profile 组合文件 | `@deepseek-ai/dsh-mcp-client` 行 |
112
58
 
113
- The differences are not cosmetic and they are where hand-editing goes wrong:
59
+ 这些差异不是美观问题,而是手改最容易翻车的地方:
114
60
 
115
- - **Remote transport is spelled differently.** Windsurf only accepts
116
- `serverUrl` — a `url` key is silently ignored. Gemini and Qwen distinguish
117
- `url` (SSE) from `httpUrl` (streamable HTTP), while Roo spells streamable
118
- HTTP `streamable-http`.
119
- - **Timeouts change unit.** Millisecond fields in Gemini/Qwen/DSH, seconds in
120
- Roo.
121
- - **Commands differ in shape.** OpenCode takes `command` as an array and `env`
122
- as `environment`; everything else takes a string plus a separate `args`.
123
- - **Some files are shared with unrelated state.** `~/.claude.json` also holds
124
- history and OAuth tokens; `opencode.json` holds providers and plugins. A
125
- whole-file rewrite destroys them.
61
+ - **远程传输的拼写不同。** Windsurf 只认 `serverUrl`,写成 `url` 会被静默忽略。Gemini 和 Qwen 区分 `url`(SSE)与 `httpUrl`(streamable HTTP),而 Roo 把 streamable HTTP 拼成 `streamable-http`。
62
+ - **超时单位不同。** Gemini / Qwen / DSH 是毫秒,Roo 是秒。
63
+ - **命令的形态不同。** OpenCode 的 `command` 是数组,`env` 叫 `environment`;其他工具是字符串 `command` 加独立的 `args`。
64
+ - **有些文件混着无关状态。** `~/.claude.json` 里还有历史记录和 OAuth token;`opencode.json` 里有 provider 和 plugin。整文件重写会把这些一起毁掉。
126
65
 
127
- This plugin reads all of it into one model and writes back only the entry it
128
- means to change.
66
+ 本插件把这一切读入同一个模型,写回时只改它想改的那一条。
129
67
 
130
- ### Windows and macOS
68
+ ### Windows 与 macOS
131
69
 
132
- Nothing is hard-coded to one platform. Each adapter declares its file through a
133
- resolver that reads the OS at module load:
70
+ 没有任何路径写死在单一平台上。每个适配器通过读取操作系统的解析器声明自己的文件:
134
71
 
135
- | root | Windows | macOS |
72
+ | 根 | Windows | macOS |
136
73
  | --- | --- | --- |
137
74
  | `appData()` | `%APPDATA%` | `~/Library/Application Support` |
138
75
  | `xdgConfig()` | `~/.config` | `~/.config` |
139
76
  | `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
140
77
  | `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
141
- | `HOME`-relative | `~/.codex`, `~/.cursor`, … | identical |
78
+ | `HOME` 相对 | `~/.codex`、`~/.cursor` … | 相同 |
142
79
 
143
- Zed is the exception worth naming: on macOS it reads `~/.config/zed`, **not**
144
- Application Support, so it gets its own resolver. `APPDATA`, `XDG_CONFIG_HOME`
145
- and `COPILOT_HOME` are honoured when set.
146
- Project-scope paths are always joined onto the workspace root, so they are
147
- portable by construction. `scripts/test-paths.mjs` loads the real adapter source
148
- under a simulated `win32`, `darwin` and `linux` and asserts the resolved
149
- directory for every client.
80
+ Zed 是值得点名的例外:macOS 上它读 `~/.config/zed`,**不是** Application Support,所以它有独立解析器。`APPDATA`、`XDG_CONFIG_HOME`、`COPILOT_HOME` 被设置时会生效。
150
81
 
151
- The file table above is written the way Windows shows it (`%APPDATA%`, `\`); on
152
- macOS the same rows resolve under `~/Library/Application Support/`. The scanner
153
- reports the real absolute path it found, so the page always shows the truth for
154
- the machine it is running on.
82
+ 项目级路径始终拼在工作区根目录上,天然可移植。`scripts/test-paths.mjs` 在模拟的 `win32`、`darwin`、`linux` 下加载真实的适配器源码,逐个客户端断言解析出的目录。
155
83
 
156
- ## Architecture
84
+ ## 架构
157
85
 
158
86
  ```
159
- client.js (settings page) index.js (host entry)
87
+ client.js (设置页) index.js (宿主入口)
160
88
  │ │
161
89
  └──────── /api/dsh-mcp-manager ────┘
162
90
  │ │
@@ -164,212 +92,109 @@ client.js (settings page) index.js (host entry)
164
92
  │ │ │
165
93
  adapters.js store.js surgical.js
166
94
  │ │ │
167
- canonical.js ledger+ byte-preserving
168
- (one model) backups JSON/TOML/YAML
95
+ canonical.js ledger+ 保字节编辑
96
+ (统一模型) 备份 JSON/TOML/YAML
169
97
  │
170
98
  dsh.js + dsh-patch.js + toggle.js
171
99
  ```
172
100
 
173
- - **`canonical.js` — the single semantic model.** Every client entry decodes to
174
- `{name, transport, command, args, env, url, headers, timeoutMs, enabled,
175
- autoApprove, extra, secrets}`. Capabilities are *not* flattened to the lowest
176
- common denominator: a field a client cannot express is dropped on write and
177
- reported as `droppedFields`, never silently invented.
178
- - **`adapters.js` — the only format-aware layer.** Eighteen clients described as
179
- data: container key, dialect (which key means url, which type strings map to
180
- which transport, timeout unit), capabilities, and where the file lives.
181
- - **`surgical.js` — byte-preserving editors.** Offset-carrying parse trees and
182
- bottom-up edits, so an entry can be replaced without reformatting the file.
183
- Comments, trailing commas, CRLF, and unrelated keys survive; unparsable input
184
- is refused rather than rewritten.
185
- - **`store.js` — the safety net.** A backup of the pre-write bytes, a
186
- compare-and-set hash check, and an ownership ledger.
187
- - **`probe.js` — does it actually work.** A hand-written JSON-RPC client (no
188
- dependency on `@modelcontextprotocol/sdk`): spawn stdio → `initialize` →
189
- `notifications/initialized` → `tools/list`; for remote, POST with
190
- `Accept: application/json, text/event-stream` and follow an SSE response.
191
- Read-only — it never invokes a tool.
192
- - **`dsh.js` / `dsh-patch.js` / `toggle.js` — Harness itself as a first-class
193
- target.** Existing rows are rewritten through `configEditor.edit()` (validated,
194
- hot-reloaded). A brand-new server has no row to address, so it goes into the
195
- composition's `- insert:` patch layer, where the Loader picks it up on its own
196
- — the operation waits for that row and reports `live: true`.
197
- If the budget expires first it reports `activating: true`, **never** "restart
198
- required": a patch row is only written where the Loader can see it, so it always
199
- arrives eventually, and telling the user to restart for a server that is already
200
- coming up was measured to be wrong here (mounting several servers back to back
201
- pushed reconciliation past any fixed budget, then all of them appeared live).
202
- `toggle.js` is the mount switch: a live row goes through
203
- `pluginManager.setPluginEnabled`, an unmounted one gets the same
204
- `disabled: true` override item the plugin manager writes, and a server that
205
- lives only in another tool is first written to the patch layer.
206
-
207
- Discovery reads `entry.options.name`, not `entry.name`: a live Loader entry
208
- has no `name` property of its own (verified against a running Host), and
209
- checking the wrong field silently matches zero rows — which made every mounted
210
- server look unreconciled and demand a restart that was never required.
211
-
212
- ## Safety model
213
-
214
- The write path is built around one rule: **only rewrite what we can prove we
215
- wrote.**
216
-
217
- 1. **Ownership ledger.** Every write records
218
- `{path, serverName, fingerprint}`. A later write of the same name is applied
219
- only if the definition on disk still matches the fingerprint — the user has
220
- not edited it in the meantime.
221
- 2. **A foreign server is never clobbered.** A name already taken by an entry we
222
- did not write is treated as the user's: the incoming server gets a suffixed
223
- name instead.
224
- 3. **Optimistic concurrency.** The caller passes the hash it scanned; a changed
225
- file returns `409` with the current hash, and the UI offers a re-scan. Silent
226
- last-write-wins is not possible.
227
- 4. **Backup before every mutation**, retained per the `backupsToKeep` config,
228
- restorable from the page.
229
- 5. **Atomic replace.** Write a temp file, `rename` over the target, then read
230
- back and re-parse to prove the result is valid.
231
- 6. **Credentials are masked before they leave the host.** Masking is
232
- key-driven, not value-driven — a short token (`TOKEN=abc`) is still masked,
233
- while an indirection (`${FIGMA_API_KEY}`) is shown as-is because it names a
234
- secret rather than carrying one. It covers every place a literal hides: env,
235
- headers, unmodelled `extra`, the raw entry copy, and command-line flags
236
- (`--figma-api-key=figd_…`, which is the documented invocation for several
237
- servers and is not an `env` entry at all). `reveal` is an explicit,
238
- confirmed action.
239
- 7. **Literal credentials are refused in project scope.** Writing a plaintext
240
- key into a file destined for a repository fails with a hint to use an
241
- environment reference.
242
- 8. **A masked definition can never be written back.** The browser only holds
243
- the redacted copy, so every write resolves the definition from the host-side
244
- scan; if a mask (`figd••••0000`) still reaches a write path it is refused
245
- with `error.server.masked` instead of replacing a real credential with
246
- bullets.
247
- 9. **Mutation requests need a custom header** (`x-dsh-mcp-manager: 1`) on top
248
- of the JSON content-type and the loopback-origin check — a cross-site form
249
- cannot set it.
250
-
251
- ## The switch mounts into DeepSeek Harness — and nothing else
252
-
253
- The one control on each row is a switch, and it has exactly one meaning: **is
254
- this server mounted in DeepSeek Harness?**
255
-
256
- Turning it on mounts the server here. Turning it off unmounts it. The file the
257
- definition came from is never opened for writing by the switch — verified by
258
- hashing every client config before and after a mount/unmount cycle on this
259
- machine, all unchanged.
260
-
261
- That restriction is not squeamishness, it is what the formats force. Most clients
262
- have no enable field at all: Qoder's and Trae's `mcp.json` carry no enable-ish key
263
- whatsoever. Writing one would be a switch that lies.
264
-
265
- How the mount works, by case:
266
-
267
- | the server is | the switch does | takes effect |
101
+ - **`canonical.js` —— 唯一的语义模型。** 每个客户端条目都解码为 `{name, transport, command, args, env, url, headers, timeoutMs, enabled, autoApprove, extra, secrets}`。能力**不**向最小公约数对齐:某个客户端表达不了的字段,写入时丢弃并作为 `droppedFields` 上报,绝不悄悄编造。
102
+ - **`adapters.js` —— 唯一懂格式的层。** 十八个客户端即数据:容器键、方言(哪个键表示 url、哪种 type 字符串映射到哪种传输、超时单位)、能力、文件位置。
103
+ - **`surgical.js` —— 保字节编辑器。** 携带偏移量的解析树 + 自底向上的编辑,替换一条不会重排整个文件。注释、尾逗号、CRLF、无关的键都活下来;解析不了就拒绝,而不是重写。
104
+ - **`store.js` —— 安全网。** 写入前字节备份、比较并交换的哈希校验、归属台账。
105
+ - **`probe.js` —— 它到底能不能用。** 手写的 JSON-RPC 客户端(不依赖 `@modelcontextprotocol/sdk`):spawn stdio → `initialize` → `notifications/initialized` → `tools/list`;远程则 POST,带 `Accept: application/json, text/event-stream` 并跟随 SSE 响应。只读 —— 从不调用任何工具。
106
+ - **`dsh.js` / `dsh-patch.js` / `toggle.js` —— 把 Harness 自己也当成一等目标。** 已存在的行通过 `configEditor.edit()` 改写(校验 + 热重载)。全新的服务器没有行可寻址,于是写进组合文件的 `- insert:` 补丁层,Loader 会自己把它拾起来 —— 操作会等这一行出现并上报 `live: true`。
107
+
108
+ 如果预算内没等到,它上报 `activating: true`,**绝不**说「需要重启」:补丁行只会写在 Loader 看得见的地方,所以它迟早会生效。给一个正在生效中的服务器提示重启是实测出来的错误 —— 连续挂载多个服务器会把重排推过任何固定预算,随后它们全部以 live 出现。
109
+
110
+ 发现逻辑读的是 `entry.options.name`,不是 `entry.name`:运行中的 Loader 条目自身没有 `name` 属性(已在运行中的 Host 上验证过),读错字段会静默匹配到零行 —— 结果每个已挂载的服务器都看起来没生效,并索要一次根本不需要的重启。
111
+
112
+ ## 安全模型
113
+
114
+ 写入路径围绕一条规则构建:**只重写我们能证明是自己写的东西。**
115
+
116
+ 1. **归属台账。** 每次写入记录 `{path, serverName, fingerprint}`。之后同名写入只有在磁盘上的定义仍与该指纹一致(用户没改过)时才生效。
117
+ 2. **绝不覆盖别人的服务器。** 名字已被非我们写入的条目占用时,视为用户的:新来的服务器改用带后缀的名字。
118
+ 3. **乐观并发。** 调用方传入它扫描到的哈希;文件变了就返回 `409` 和当前哈希,界面提示重新扫描。不存在静默的后写覆盖。
119
+ 4. **每次修改前备份**,按 `backupsToKeep` 保留,可在页面还原。
120
+ 5. **原子替换。** 写临时文件、`rename` 覆盖目标、再读回并重新解析,证明结果是合法的。
121
+ 6. **凭据在离开宿主前就被掩码。** 掩码是按键驱动的,不是按值形状 —— 短 token(`TOKEN=abc`)照样掩码,而间接引用(`${FIGMA_API_KEY}`)原样显示,因为它指向密钥名而不是携带密钥。覆盖字面值所有可能藏身的地方:`env`、`headers`、未建模的 `extra`、原始条目副本,以及命令行参数(`--figma-api-key=figd_…`,这是好几个服务器的官方调用方式,根本不在 `env` 里)。`reveal` 是需要显式确认的动作。
122
+ 7. **项目作用域拒绝字面凭据。** 往要进仓库的文件里写明文密钥会失败,并提示改用环境变量引用。
123
+ 8. **被掩码的定义永远写不回去。** 浏览器手里只有脱敏副本,所以每次写入都从宿主侧的扫描结果解析定义;万一 `figd••••0000` 还是流到了写入路径,会以 `error.server.masked` 拒绝,而不是把真实凭据换成圆点。
124
+ 9. **变更请求需要自定义头**(`x-dsh-mcp-manager: 1`),叠加在 JSON content-type 和 loopback 来源校验之上 —— 跨站表单设不了这个头。
125
+
126
+ ## 开关只把服务器挂进 DeepSeek Harness,别的什么都不动
127
+
128
+ 每行上的唯一控件是一个开关,含义只有一条:**这个服务器有没有挂进 DeepSeek Harness?**
129
+
130
+ 打开就在 Harness 里挂载,关闭就卸载。定义来自哪个文件,开关**从不**以写入方式打开 —— 通过在本机对一次挂载/卸载循环前后哈希每个客户端配置来验证,全部未变。
131
+
132
+ 这个限制不是洁癖,是格式逼出来的:大多数客户端根本没有 enable 字段。Qoder 和 Trae 的 `mcp.json` 里没有任何 enable 之类的键。写一个进去,就是一个会撒谎的开关。
133
+
134
+ 按情形说明挂载怎么实现:
135
+
136
+ | 服务器是 | 开关做的事 | 生效时机 |
268
137
  | --- | --- | --- |
269
- | an `- insert:` item we wrote | deletes the item on off, rewrites it on on | on reconcile; the file returns to its exact previous bytes |
270
- | already a live `dsh-mcp-client` row | `pluginManager.setPluginEnabled(rowId, …)` | instantly — the row unmounts and its tools leave the tool list |
271
- | an `- insert:` item the user wrote | appends/removes the same `- id: …` / `disabled: true` override the plugin manager itself writes | on reconcile |
272
- | declared in another tool only, and switched on | writes a new `- insert:` row | live as soon as the Loader reconciles it (usually 1–4 s); the operation waits up to 8 s and reports `live: true`, else `activating: true` |
273
- | not mounted anywhere, and switched off | nothing — already true, no dangling override | — |
274
-
275
- Our own row is removed before the Loader path is tried: the plugin manager can
276
- dispose a row hot, but it does so by appending a `disabled: true` override beside
277
- the definition, which would leave both our row and that override in the user's
278
- composition permanently. Removing the insert item is both the unmount and the
279
- cleanup.
280
-
281
- Because the override is a separate item and not a rewrite, and because a row we
282
- wrote is removed rather than disabled, a full mount → unmount cycle returns
283
- `cordis.patch.yml` to its exact previous bytes.
284
-
285
- ### A mount name is not always the display name
286
-
287
- `@deepseek-ai/dsh-mcp-client` requires `serverName` to match
288
- `/^[A-Za-z0-9_-]{1,32}$/`. Many real servers do not — this machine has a dozen
289
- named `表单管理系统 - API 文档` and friends, and `Figma AI Bridge` has a space.
290
- Squeezing those through `sanitizeName` alone gives `API` for every Chinese name,
291
- so a second mount would collide with the first and the switch would flip the wrong
292
- server.
293
-
294
- `canonical.js` therefore exports `dshMountName(name)`: sanitize, and if the result
295
- lost information (CJK characters, spaces, over-length) append a short hash of the
296
- original — `表单管理系统 - API 文档` → `API-c2f500c4`, `Figma AI Bridge` →
297
- `Figma-AI-Bridge`. It is pure and total, so the write path, the scan, the toggle
298
- and the removal all compute the same string from the same input without storing a
299
- mapping anywhere.
300
-
301
- Consequences the scan and the page handle:
302
-
303
- - the node keeps its display name; `mountedAs` carries the row name and `entryId`
304
- the Loader id, so the switch finds its row under the mapped name;
305
- - the server appears under the DSH file card with its display name, and the
306
- original tools' cards are unchanged;
307
- - two servers that sanitize to the same base still get distinct row names (the
308
- hash differs), so no server silently replaces another.
309
-
310
- `scripts/test-mount-name.mjs` pins the mapping, including the collision case and
311
- the round trip through scan.
312
-
313
- ## Using it
314
-
315
- The page is laid out like the skills page it sits next to: a title row with the
316
- actions, a four-cell summary strip (servers · files · mounted in DSH · literal
317
- credentials), a filter row, then one bordered card per config file.
318
-
319
- Each card lists the servers that file declares — headed by the tool name, the
320
- server count and the path (shortened to `%APPDATA%\…` style, full path in the
321
- tooltip). A card collapses; while a filter is active every card opens, because a
322
- match hidden inside a collapsed card reads as "no results". Every row shows the
323
- transport, the command or URL, how many tools share the definition, its probe
324
- result, and the mount switch.
325
-
326
- The filters are a free-text search (name, command, path) plus two selects: by
327
- tool, and by mount state (mounted / not mounted).
328
-
329
- Clicking a row opens a dialog rather than expanding it in place — inline
330
- expansion reflows every row below and made the card look like two stacked cards.
331
- The dialog lists every file that declares the server, the canonical definition,
332
- a paste-ready YAML row for a cordis composition, and "copy to…" for pushing the
333
- definition into another tool.
334
-
335
- The same operations are exposed to the agent as the `mcp_manage` tool, so the
336
- page and the agent share one implementation and cannot diverge:
138
+ | 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
139
+ | 已经是运行中的 `dsh-mcp-client` 行 | `pluginManager.setPluginEnabled(rowId, …)` | 立即 —— 行卸载,其工具从工具列表消失 |
140
+ | 用户自己写的 `- insert:` 项 | 追加/移除插件管理器自己也会写的那种 `- id: …` / `disabled: true` 覆盖项 | 重排后生效 |
141
+ | 只声明在别的工具里,且被打开 | 写一条新的 `- insert:` 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 `live: true`,否则 `activating: true` |
142
+ | 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
143
+
144
+ 我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 `disabled: true` 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
145
+
146
+ 因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
147
+
148
+ ### 挂载名不总是显示名
149
+
150
+ `@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 大量中文名形如 `示例服务器 - API 文档`,也有带空格的英文名形如 `My Bridge`。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
151
+
152
+ 因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `示例服务器 - API 文档` → `API-0aac0ee2`,`My Bridge` → `My-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
153
+
154
+ 扫描和页面处理的后果:
155
+
156
+ - 节点保留显示名;`mountedAs` 携带行名,`entryId` 携带 Loader id,开关按映射后的名字找到自己的行;
157
+ - 服务器出现在 DSH 那张文件卡片里时用显示名,其他工具的卡片不变;
158
+ - 两个 sanitize 后同名基的服务器仍得到不同的行名(哈希不同),不会有服务器悄悄替换另一个。
159
+
160
+ `scripts/test-mount-name.mjs` 钉住了这个映射,包括撞名情形和经由 scan 的往返。
161
+
162
+ ## 怎么用
163
+
164
+ 页面布局和它旁边的技能页一致:标题行加操作按钮,四格汇总条(服务器 · 文件 · 已挂载进 DSH · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
165
+
166
+ 每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、有多少工具共用这份定义、探活结果,以及挂载开关。
167
+
168
+ 筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按挂载状态(已挂载 / 未挂载)。
169
+
170
+ 点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
171
+
172
+ 同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
337
173
 
338
174
  ```
339
175
  action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
340
176
  ```
341
177
 
342
- ## Configuration
178
+ ## 配置
343
179
 
344
- `cordis.patch.yml` row config:
180
+ `cordis.patch.yml` 行配置:
345
181
 
346
- | key | default | meaning |
182
+ | 键 | 默认 | 含义 |
347
183
  | --- | --- | --- |
348
- | `includeProjectScope` | `true` | also scan workspace-level files |
349
- | `probeTimeoutMs` | `20000` | per-server probe budget |
350
- | `probeConcurrency` | `4` | parallel probes |
351
- | `backupsToKeep` | `20` | retained backups per file |
184
+ | `includeProjectScope` | `true` | 同时扫描工作区级文件 |
185
+ | `probeTimeoutMs` | `20000` | 单个服务器探活预算 |
186
+ | `probeConcurrency` | `4` | 并行探活数 |
187
+ | `backupsToKeep` | `20` | 每个文件保留的备份数 |
352
188
 
353
- State (ledger + backups) lives in `<DSH_HOME>/mcp-manager/`.
189
+ 状态(台账 + 备份)位于 `<DSH_HOME>/mcp-manager/`。
354
190
 
355
- ## Verified on this machine
191
+ ## 在本机验证过
356
192
 
357
- Eight of eighteen clients installed, 27 servers discovered across Qoder IDE,
358
- OpenCode, Trae, Trae CN and DSH. Context7 probes OK with 2 tools,
359
- `playwright_mcp` with 25. A DSH server mounted and unmounted through the page
360
- leaves `cordis.patch.yml` **byte-identical** to its pre-write hash, with the
361
- user's own provider, permission and agency entries untouched — and every other
362
- client configuration file keeps its hash through the same cycle.
193
+ 十八个客户端中装好了八个,跨 Qoder IDE、OpenCode、Trae、Trae CN 和 DSH 发现 27 个服务器。Context7 探活成功带 2 个工具,`playwright_mcp` 带 25 个。经页面挂载再卸载一个 DSH 服务器后,`cordis.patch.yml` 与写入前哈希**逐字节相同**,用户自己的 provider、permission、agency 条目原封不动 —— 同一循环中其他每个客户端配置文件也都保持哈希不变。
363
194
 
364
- Twenty-one plaintext credentials were found on disk — most in `env` maps and
365
- one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
366
- their literals appears anywhere in the state payload; the page shows only key
367
- names, and the write path refuses a masked value.
195
+ 磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
368
196
 
369
- Twelve test suites under `scripts/` cover the editors, the path layer, the write
370
- path, the DSH adapter, the patch layer, the mount switch, the mount-name mapping
371
- for non-ASCII server names, redaction and the masked-write guard, plus an
372
- end-to-end pass that asserts no credential literal reaches the client payload:
197
+ `scripts/` 下十三个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护、发布元数据一致性,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
373
198
 
374
199
  ```
375
200
  node scripts/test-surgical.mjs 17 passing
@@ -384,25 +209,20 @@ node scripts/test-client-load.mjs 28 passing
384
209
  node scripts/test-paths.mjs 16 passing
385
210
  node scripts/test-mount-name.mjs 12 passing
386
211
  node scripts/test-locale.mjs en/zh key parity
212
+ node scripts/test-release.mjs 11 passing
387
213
  ```
388
214
 
389
- ## Known limits
390
-
391
- - **Install detection is file presence.** An installed tool with no MCP config
392
- yet is indistinguishable from an absent one until its directory exists.
393
- - **A newly mounted DSH server relies on the Loader reconciling it.** The
394
- operation waits up to 8 s for the row and reports `live: true`; if that window
395
- expires it reports `activating: true` and the page re-reads the state a moment
396
- later. No restart is ever demanded for a row the Loader can see.
397
- - **A row we did not write is switched off, not removed.** Deleting a definition
398
- the user pasted would destroy their work, so such a row gets the same
399
- `disabled: true` override the plugin manager writes, and stays in the file.
400
- - **Only DSH is switchable.** The other tools' files are read and written
401
- explicitly, never toggled — see the section above.
402
- - **Continue is read-only** (YAML array shape); editing it is planned.
403
- - **Secrets are not migrated to a vault.** The plugin masks and warns; moving
404
- credentials into `${env:...}` references stays the user's decision.
405
- - **The MCP registry is not consulted.** `registry.modelcontextprotocol.io` is
406
- explicitly preview and not intended for direct host consumption, so server
407
- discovery is by scanning local files only.
408
-
215
+ ## 已知限制
216
+
217
+ - **安装检测靠文件存在性。** 装了但还没有 MCP 配置的工具,在目录出现之前与「没装」无法区分。
218
+ - **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
219
+ - **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
220
+ - **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
221
+ - **Continue 只读**(YAML 数组形态);编辑在计划中。
222
+ - **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
223
+ - **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
224
+
225
+ ## 许可证
226
+
227
+ MIT
228
+
package/lib/canonical.js CHANGED
@@ -267,12 +267,12 @@ export function sanitizeName(raw, fallback = 'server') {
267
267
  *
268
268
  * The harness's own `@deepseek-ai/dsh-mcp-client` constrains `serverName` to
269
269
  * `/^[A-Za-z0-9_-]{1,32}$/` (lib/index.js of that package, the zod schema and
270
- * `SERVER_NAME_PATTERN`), so a name like `表单管理系统 - API 文档` CANNOT be used
270
+ * `SERVER_NAME_PATTERN`), so a name like `示例服务器 - API 文档` CANNOT be used
271
271
  * verbatim. It has to be mapped.
272
272
  *
273
273
  * `sanitizeName` alone is not a usable mapping here: every CJK name collapses
274
- * to whatever ASCII fragments it contains — `表单管理系统 - API 文档`,
275
- * `方案生成助手 - API 文档` and `心理健康关爱系统 - API 文档` ALL become `API`, and
274
+ * to whatever ASCII fragments it contains — `示例服务器 - API 文档`,
275
+ * `示例助手 - API 文档` and `另一个服务 - API 文档` ALL become `API`, and
276
276
  * for a fully-CJK name it falls back to the constant `server`. That collision is
277
277
  * not cosmetic. It produced two visible bugs: the mount row was created under
278
278
  * `API`, the page looked the mount up by the display name (found nothing), so
@@ -283,7 +283,7 @@ export function sanitizeName(raw, fallback = 'server') {
283
283
  *
284
284
  * 1. An all-ASCII name keeps `sanitizeName`'s result unchanged. Those names
285
285
  * are already readable and already distinct from each other, so
286
- * `Figma AI Bridge` stays `Figma-AI-Bridge` and `my server!` stays
286
+ * `My Bridge` stays `My-Bridge` and `my server!` stays
287
287
  * `my-server` — which also means rows mounted before this mapping existed
288
288
  * keep their identity.
289
289
  * 2. A name with any non-ASCII character — CJK, full-width punctuation,
package/lib/probe.js CHANGED
@@ -25,7 +25,7 @@ const PROTOCOL_VERSION = '2025-06-18';
25
25
  * connection to this plugin and a version we still recognise. `test-release.mjs`
26
26
  * asserts it matches package.json — a stale value here is otherwise invisible.
27
27
  */
28
- const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.1' };
28
+ const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.3' };
29
29
 
30
30
  function rpcRequest(id, method, params) {
31
31
  return JSON.stringify({ jsonrpc: '2.0', id, method, params });
package/lib/scan.js CHANGED
@@ -80,7 +80,7 @@ function readDsh(configEditor, profileDir) {
80
80
  * A server mounted in the harness lives under `dshMountName(displayName)`,
81
81
  * because `serverName` may only use `[A-Za-z0-9_-]` — a CJK name has to be
82
82
  * mapped. Without this pass the mounted row appears as a SEVENTH server named
83
- * `API-c2f500c4` next to `表单管理系统 - API 文档`, and the display-named node has
83
+ * `API-0aac0ee2` next to `示例服务器 - API 文档`, and the display-named node has
84
84
  * no `dsh` target at all, so its switch reads "off" while the server is in fact
85
85
  * mounted.
86
86
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-agent/dsh-mcp-manager",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "One settings page for every MCP server on your machine: scan each AI tool's own config file, see which directory holds which server, probe whether it really starts, and mount or unmount it in DeepSeek Harness without ever editing the other tools' files. / 一个设置页管理本机所有 AI 开发工具的 MCP:按配置文件分组展示、真连探活、只在 DeepSeek Harness 内挂载/卸载,绝不改动其他工具的配置。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "cordis.patch.yml",
20
20
  "icon.svg",
21
21
  "README.md",
22
- "README.zh.md",
22
+ "README.en.md",
23
23
  "LICENSE"
24
24
  ],
25
25
  "dsh": {
package/README.zh.md DELETED
@@ -1,225 +0,0 @@
1
- # @oh-agent/dsh-mcp-manager
2
-
3
- 一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来。
4
-
5
- 入口:DeepSeek Harness → **设置 → MCP**(位于「技能管理」和「插件市场」之间)。
6
-
7
- ## 安装
8
-
9
- **方式一:插件管理页**(推荐)
10
-
11
- 设置 → 插件管理 → 安装,搜索 `@oh-agent/dsh-mcp-manager`。
12
-
13
- **方式二:让 agent 安装**
14
-
15
- 直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
16
-
17
- ```
18
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.1"
19
- ```
20
-
21
- `target` 也可以填一个绝对目录 —— 本地源码目录,或者
22
- [v0.1.1 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
23
- `dsh-mcp-manager.tgz` 解出来的目录。
24
-
25
- **不要**手动 `pnpm add` 进 profile,也不要手改 profile 的 `package.json` 或
26
- `cordis.patch.yml`:装了包不等于选中了它的 bundle,手动装的后果是那一行存在但永远不会被挂载。
27
-
28
- 安装后增加一个设置页和一个 agent 工具(`mcp_manage`)。它没有任何运行时依赖,不需要构建,也没有 `allowBuilds` 授权步骤。要求 Node `^22.19.0 || >=24.0.0`(DeepSeek Harness 自带 Node 24,无需另装)。
29
-
30
- 页面会读取其他工具的配置文件,但**开关不需要对它们有写权限**。所有数据都不离开本机 —— 唯一的对外流量是你主动点的那次探活,且只发往你点的那一个服务器。
31
-
32
- ## 它解决什么问题
33
-
34
- 同一个 MCP 服务器,在每个工具里声明在不同的文件、不同的键、不同的方言下:
35
-
36
- | 工具 | 文件(Windows) | 顶层键 |
37
- | --- | --- | --- |
38
- | Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` |
39
- | Claude Code | `~/.claude.json`、`<proj>/.mcp.json` | `mcpServers` |
40
- | Cursor | `~/.cursor/mcp.json` | `mcpServers` |
41
- | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
42
- | VS Code | `<ws>/.vscode/mcp.json` | `servers` |
43
- | Roo Code | `…\rooveterinaryinc.roo-cline\settings\cline_mcp_settings.json` | `mcpServers` |
44
- | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
45
- | Qwen Code | `~/.qwen/settings.json` | `mcpServers` |
46
- | Qoder IDE | `%APPDATA%\Qoder\SharedClientCache\mcp.json` | `mcpServers` |
47
- | OpenCode | `~/.config/opencode/opencode.json` | `mcp` |
48
- | Trae | `%APPDATA%\Trae\User\mcp.json` | `mcpServers` |
49
- | Trae CN | `%APPDATA%\Trae CN\User\mcp.json` | `mcpServers` |
50
- | Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` |
51
- | Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` |
52
- | Zed | `settings.json` | `context_servers` |
53
- | Continue | `~/.continue/config.yaml` | `mcpServers`(YAML 数组) |
54
- | CodeBuddy | `%APPDATA%\CodeBuddy\...` | `mcpServers` |
55
- | **DeepSeek Harness** | profile 组合文件 | `@deepseek-ai/dsh-mcp-client` 行 |
56
-
57
- 这些差异不是美观问题,而是手改最容易翻车的地方:
58
-
59
- - **远程传输的拼写不同。** Windsurf 只认 `serverUrl`,写成 `url` 会被静默忽略。Gemini 和 Qwen 区分 `url`(SSE)与 `httpUrl`(streamable HTTP),而 Roo 把 streamable HTTP 拼成 `streamable-http`。
60
- - **超时单位不同。** Gemini / Qwen / DSH 是毫秒,Roo 是秒。
61
- - **命令的形态不同。** OpenCode 的 `command` 是数组,`env` 叫 `environment`;其他工具是字符串 `command` 加独立的 `args`。
62
- - **有些文件混着无关状态。** `~/.claude.json` 里还有历史记录和 OAuth token;`opencode.json` 里有 provider 和 plugin。整文件重写会把这些一起毁掉。
63
-
64
- 本插件把这一切读入同一个模型,写回时只改它想改的那一条。
65
-
66
- ### Windows 与 macOS
67
-
68
- 没有任何路径写死在单一平台上。每个适配器通过读取操作系统的解析器声明自己的文件:
69
-
70
- | 根 | Windows | macOS |
71
- | --- | --- | --- |
72
- | `appData()` | `%APPDATA%` | `~/Library/Application Support` |
73
- | `xdgConfig()` | `~/.config` | `~/.config` |
74
- | `vscodeUserDir()` | `%APPDATA%\Code\User` | `~/Library/Application Support/Code/User` |
75
- | `zedConfig()` | `%APPDATA%\Zed` | `~/.config/zed` |
76
- | `HOME` 相对 | `~/.codex`、`~/.cursor` … | 相同 |
77
-
78
- Zed 是值得点名的例外:macOS 上它读 `~/.config/zed`,**不是** Application Support,所以它有独立解析器。`APPDATA`、`XDG_CONFIG_HOME`、`COPILOT_HOME` 被设置时会生效。
79
-
80
- 项目级路径始终拼在工作区根目录上,天然可移植。`scripts/test-paths.mjs` 在模拟的 `win32`、`darwin`、`linux` 下加载真实的适配器源码,逐个客户端断言解析出的目录。
81
-
82
- ## 架构
83
-
84
- ```
85
- client.js (设置页) index.js (宿主入口)
86
- │ │
87
- └──────── /api/dsh-mcp-manager ────┘
88
- │ │
89
- scan.js ops.js probe.js
90
- │ │ │
91
- adapters.js store.js surgical.js
92
- │ │ │
93
- canonical.js ledger+ 保字节编辑
94
- (统一模型) 备份 JSON/TOML/YAML
95
- │
96
- dsh.js + dsh-patch.js + toggle.js
97
- ```
98
-
99
- - **`canonical.js` —— 唯一的语义模型。** 每个客户端条目都解码为 `{name, transport, command, args, env, url, headers, timeoutMs, enabled, autoApprove, extra, secrets}`。能力**不**向最小公约数对齐:某个客户端表达不了的字段,写入时丢弃并作为 `droppedFields` 上报,绝不悄悄编造。
100
- - **`adapters.js` —— 唯一懂格式的层。** 十八个客户端即数据:容器键、方言(哪个键表示 url、哪种 type 字符串映射到哪种传输、超时单位)、能力、文件位置。
101
- - **`surgical.js` —— 保字节编辑器。** 携带偏移量的解析树 + 自底向上的编辑,替换一条不会重排整个文件。注释、尾逗号、CRLF、无关的键都活下来;解析不了就拒绝,而不是重写。
102
- - **`store.js` —— 安全网。** 写入前字节备份、比较并交换的哈希校验、归属台账。
103
- - **`probe.js` —— 它到底能不能用。** 手写的 JSON-RPC 客户端(不依赖 `@modelcontextprotocol/sdk`):spawn stdio → `initialize` → `notifications/initialized` → `tools/list`;远程则 POST,带 `Accept: application/json, text/event-stream` 并跟随 SSE 响应。只读 —— 从不调用任何工具。
104
- - **`dsh.js` / `dsh-patch.js` / `toggle.js` —— 把 Harness 自己也当成一等目标。** 已存在的行通过 `configEditor.edit()` 改写(校验 + 热重载)。全新的服务器没有行可寻址,于是写进组合文件的 `- insert:` 补丁层,Loader 会自己把它拾起来 —— 操作会等这一行出现并上报 `live: true`。
105
-
106
- 如果预算内没等到,它上报 `activating: true`,**绝不**说「需要重启」:补丁行只会写在 Loader 看得见的地方,所以它迟早会生效。给一个正在生效中的服务器提示重启是实测出来的错误 —— 连续挂载多个服务器会把重排推过任何固定预算,随后它们全部以 live 出现。
107
-
108
- 发现逻辑读的是 `entry.options.name`,不是 `entry.name`:运行中的 Loader 条目自身没有 `name` 属性(已在运行中的 Host 上验证过),读错字段会静默匹配到零行 —— 结果每个已挂载的服务器都看起来没生效,并索要一次根本不需要的重启。
109
-
110
- ## 安全模型
111
-
112
- 写入路径围绕一条规则构建:**只重写我们能证明是自己写的东西。**
113
-
114
- 1. **归属台账。** 每次写入记录 `{path, serverName, fingerprint}`。之后同名写入只有在磁盘上的定义仍与该指纹一致(用户没改过)时才生效。
115
- 2. **绝不覆盖别人的服务器。** 名字已被非我们写入的条目占用时,视为用户的:新来的服务器改用带后缀的名字。
116
- 3. **乐观并发。** 调用方传入它扫描到的哈希;文件变了就返回 `409` 和当前哈希,界面提示重新扫描。不存在静默的后写覆盖。
117
- 4. **每次修改前备份**,按 `backupsToKeep` 保留,可在页面还原。
118
- 5. **原子替换。** 写临时文件、`rename` 覆盖目标、再读回并重新解析,证明结果是合法的。
119
- 6. **凭据在离开宿主前就被掩码。** 掩码是按键驱动的,不是按值形状 —— 短 token(`TOKEN=abc`)照样掩码,而间接引用(`${FIGMA_API_KEY}`)原样显示,因为它指向密钥名而不是携带密钥。覆盖字面值所有可能藏身的地方:`env`、`headers`、未建模的 `extra`、原始条目副本,以及命令行参数(`--figma-api-key=figd_…`,这是好几个服务器的官方调用方式,根本不在 `env` 里)。`reveal` 是需要显式确认的动作。
120
- 7. **项目作用域拒绝字面凭据。** 往要进仓库的文件里写明文密钥会失败,并提示改用环境变量引用。
121
- 8. **被掩码的定义永远写不回去。** 浏览器手里只有脱敏副本,所以每次写入都从宿主侧的扫描结果解析定义;万一 `figd••••0000` 还是流到了写入路径,会以 `error.server.masked` 拒绝,而不是把真实凭据换成圆点。
122
- 9. **变更请求需要自定义头**(`x-dsh-mcp-manager: 1`),叠加在 JSON content-type 和 loopback 来源校验之上 —— 跨站表单设不了这个头。
123
-
124
- ## 开关只把服务器挂进 DeepSeek Harness,别的什么都不动
125
-
126
- 每行上的唯一控件是一个开关,含义只有一条:**这个服务器有没有挂进 DeepSeek Harness?**
127
-
128
- 打开就在 Harness 里挂载,关闭就卸载。定义来自哪个文件,开关**从不**以写入方式打开 —— 通过在本机对一次挂载/卸载循环前后哈希每个客户端配置来验证,全部未变。
129
-
130
- 这个限制不是洁癖,是格式逼出来的:大多数客户端根本没有 enable 字段。Qoder 和 Trae 的 `mcp.json` 里没有任何 enable 之类的键。写一个进去,就是一个会撒谎的开关。
131
-
132
- 按情形说明挂载怎么实现:
133
-
134
- | 服务器是 | 开关做的事 | 生效时机 |
135
- | --- | --- | --- |
136
- | 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
137
- | 已经是运行中的 `dsh-mcp-client` 行 | `pluginManager.setPluginEnabled(rowId, …)` | 立即 —— 行卸载,其工具从工具列表消失 |
138
- | 用户自己写的 `- insert:` 项 | 追加/移除插件管理器自己也会写的那种 `- id: …` / `disabled: true` 覆盖项 | 重排后生效 |
139
- | 只声明在别的工具里,且被打开 | 写一条新的 `- insert:` 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 `live: true`,否则 `activating: true` |
140
- | 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
141
-
142
- 我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 `disabled: true` 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
143
-
144
- 因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
145
-
146
- ### 挂载名不总是显示名
147
-
148
- `@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 这台机器上有十几个叫 `表单管理系统 - API 文档` 之类,`Figma AI Bridge` 带空格。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
149
-
150
- 因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `表单管理系统 - API 文档` → `API-c2f500c4`,`Figma AI Bridge` → `Figma-AI-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
151
-
152
- 扫描和页面处理的后果:
153
-
154
- - 节点保留显示名;`mountedAs` 携带行名,`entryId` 携带 Loader id,开关按映射后的名字找到自己的行;
155
- - 服务器出现在 DSH 那张文件卡片里时用显示名,其他工具的卡片不变;
156
- - 两个 sanitize 后同名基的服务器仍得到不同的行名(哈希不同),不会有服务器悄悄替换另一个。
157
-
158
- `scripts/test-mount-name.mjs` 钉住了这个映射,包括撞名情形和经由 scan 的往返。
159
-
160
- ## 怎么用
161
-
162
- 页面布局和它旁边的技能页一致:标题行加操作按钮,四格汇总条(服务器 · 文件 · 已挂载进 DSH · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
163
-
164
- 每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、有多少工具共用这份定义、探活结果,以及挂载开关。
165
-
166
- 筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按挂载状态(已挂载 / 未挂载)。
167
-
168
- 点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
169
-
170
- 同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
171
-
172
- ```
173
- action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
174
- ```
175
-
176
- ## 配置
177
-
178
- `cordis.patch.yml` 行配置:
179
-
180
- | 键 | 默认 | 含义 |
181
- | --- | --- | --- |
182
- | `includeProjectScope` | `true` | 同时扫描工作区级文件 |
183
- | `probeTimeoutMs` | `20000` | 单个服务器探活预算 |
184
- | `probeConcurrency` | `4` | 并行探活数 |
185
- | `backupsToKeep` | `20` | 每个文件保留的备份数 |
186
-
187
- 状态(台账 + 备份)位于 `<DSH_HOME>/mcp-manager/`。
188
-
189
- ## 在本机验证过
190
-
191
- 十八个客户端中装好了八个,跨 Qoder IDE、OpenCode、Trae、Trae CN 和 DSH 发现 27 个服务器。Context7 探活成功带 2 个工具,`playwright_mcp` 带 25 个。经页面挂载再卸载一个 DSH 服务器后,`cordis.patch.yml` 与写入前哈希**逐字节相同**,用户自己的 provider、permission、agency 条目原封不动 —— 同一循环中其他每个客户端配置文件也都保持哈希不变。
192
-
193
- 磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
194
-
195
- `scripts/` 下十二个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
196
-
197
- ```
198
- node scripts/test-surgical.mjs 17 passing
199
- node scripts/test-write.mjs 12 passing
200
- node scripts/test-dsh.mjs 16 passing
201
- node scripts/test-dsh-patch.mjs 15 passing
202
- node scripts/test-dsh-e2e.mjs 8 passing
203
- node scripts/test-toggle.mjs 16 passing
204
- node scripts/test-redact.mjs 14 passing
205
- node scripts/test-masked-write.mjs 5 passing
206
- node scripts/test-client-load.mjs 28 passing
207
- node scripts/test-paths.mjs 16 passing
208
- node scripts/test-mount-name.mjs 12 passing
209
- node scripts/test-locale.mjs en/zh key parity
210
- ```
211
-
212
- ## 已知限制
213
-
214
- - **安装检测靠文件存在性。** 装了但还没有 MCP 配置的工具,在目录出现之前与「没装」无法区分。
215
- - **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
216
- - **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
217
- - **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
218
- - **Continue 只读**(YAML 数组形态);编辑在计划中。
219
- - **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
220
- - **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
221
-
222
- ## 许可证
223
-
224
- MIT
225
-