@oh-agent/dsh-mcp-manager 0.1.2 → 0.1.4

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 CHANGED
@@ -4,8 +4,9 @@ English · [中文](README.md)
4
4
 
5
5
  Management page for the MCP servers scattered across every local AI development
6
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.
7
+ server that mounts it in DeepSeek Harness, one probe of what actually answers,
8
+ and, more importantly, a straight answer to whether it **really loaded** in the
9
+ harness, with a retry button when it did not.
9
10
 
10
11
  DeepSeek Harness → **Settings → MCP Manager** (between Skills Manager and
11
12
  Market).
@@ -16,12 +17,12 @@ Install the bundle from the **Plugin Manager** page in Settings, or point
16
17
  `plugin_manager` at the npm package:
17
18
 
18
19
  ```
19
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.2"
20
+ plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.4"
20
21
  ```
21
22
 
22
23
  An absolute directory works as `target` too, which is how you install a local
23
24
  checkout or the `dsh-mcp-manager.tgz` attached to the
24
- [v0.1.2 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
25
+ [v0.1.4 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
25
26
 
26
27
  Do not `pnpm add` the package into the profile by hand, and do not edit the
27
28
  profile's `package.json` or `cordis.patch.yml`: installing a package is not the
@@ -234,19 +235,60 @@ Because the override is a separate item and not a rewrite, and because a row we
234
235
  wrote is removed rather than disabled, a full mount → unmount cycle returns
235
236
  `cordis.patch.yml` to its exact previous bytes.
236
237
 
238
+ ### Mounted is not loaded
239
+
240
+ This is the field most likely to lie. A row existing in `cordis.patch.yml` proves
241
+ only that it was **mounted**; it says nothing about whether `npx` started or the
242
+ handshake succeeded.
243
+
244
+ `@deepseek-ai/dsh-mcp-client` ships `failOnStartupError: false` by default, which
245
+ buries the failure: `apply()` awaits `connection.ready` and, on error, simply
246
+ returns instead of throwing. So the row's `fiberPhase` in the Plugin Manager
247
+ stays `active` — every indicator is green and there are still no tools. Switch
248
+ Node to 14 and start the harness and this is exactly what you get: the old Node
249
+ runtime cannot run a modern MCP server, the process exits immediately, and
250
+ nothing in the UI reports anything wrong.
251
+
252
+ So this plugin ignores `fiberPhase` and reads the **tool registry** instead. A
253
+ server that connected successfully necessarily registered tools named
254
+ `mcp__<serverName>__<tool>`. Some are present → `loaded`; none are → `failed`.
255
+ That is the part that tells the truth.
256
+
257
+ | What the page shows | What it means |
258
+ | --- | --- |
259
+ | `loaded · N tools` | Connection succeeded, tools registered, the agent can call them now |
260
+ | `mounted but failed to load` + Retry | The row is mounted but no tool registered — the process never started, or the handshake failed |
261
+ | `activating` | The patch row was just written; the Loader has not recomposed yet |
262
+ | `load state unknown` | The tool registry cannot be read (older host) — **no conclusion is drawn**, and it is never reported as a failure |
263
+
264
+ Reporting "unknown" rather than "failed" when the registry is unavailable is
265
+ deliberate: marking a healthy server as broken is worse than marking nothing.
266
+
267
+ Retry goes through `hmr.reconcileProfile([entryId])`, serialized inside
268
+ `hmr.runExclusive`. That makes the Loader re-import the rows that failed and
269
+ judge activation. Backoff retries after a failed connection are the mcp-client's
270
+ own business, but once it gives up it registers nothing and waits for a reload
271
+ that a settings page cannot deliver — so the reload has to come from us.
272
+ Reconnecting is not instant (process spawn plus handshake), so the page re-reads
273
+ state 2.5 seconds after the button is pressed.
274
+
275
+ `scripts/test-load.mjs` pins this down, mostly the boundary between `unknown`,
276
+ `failed` and `loaded`, plus the name matching that decides which tools belong to
277
+ which server.
278
+
237
279
  ### A mount name is not always the display name
238
280
 
239
281
  `@deepseek-ai/dsh-mcp-client` requires `serverName` to match
240
- `/^[A-Za-z0-9_-]{1,32}$/`. Many real servers do not — this machine has a dozen
241
- named `表单管理系统 - API 文档` and friends, and `Figma AI Bridge` has a space.
282
+ `/^[A-Za-z0-9_-]{1,32}$/`. Many real servers do not — Chinese names commonly look
283
+ like `示例服务器 - API 文档`, and an English name like `My Bridge` carries a space.
242
284
  Squeezing those through `sanitizeName` alone gives `API` for every Chinese name,
243
285
  so a second mount would collide with the first and the switch would flip the wrong
244
286
  server.
245
287
 
246
288
  `canonical.js` therefore exports `dshMountName(name)`: sanitize, and if the result
247
289
  lost information (CJK characters, spaces, over-length) append a short hash of the
248
- original — `表单管理系统 - API 文档` → `API-c2f500c4`, `Figma AI Bridge` →
249
- `Figma-AI-Bridge`. It is pure and total, so the write path, the scan, the toggle
290
+ original — `示例服务器 - API 文档` → `API-0aac0ee2`, `My Bridge` →
291
+ `My-Bridge`. It is pure and total, so the write path, the scan, the toggle
250
292
  and the removal all compute the same string from the same input without storing a
251
293
  mapping anywhere.
252
294
 
@@ -265,18 +307,18 @@ the round trip through scan.
265
307
  ## Using it
266
308
 
267
309
  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.
310
+ actions, a five-cell summary strip (servers · files · mounted in DSH · loaded ·
311
+ literal credentials), a filter row, then one bordered card per config file.
270
312
 
271
313
  Each card lists the servers that file declares — headed by the tool name, the
272
314
  server count and the path (shortened to `%APPDATA%\…` style, full path in the
273
315
  tooltip). A card collapses; while a filter is active every card opens, because a
274
316
  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.
317
+ transport, the command or URL, its **load state**, how many tools share the
318
+ definition, its probe result, and the mount switch.
277
319
 
278
320
  The filters are a free-text search (name, command, path) plus two selects: by
279
- tool, and by mount state (mounted / not mounted).
321
+ tool, and by state (mounted / not mounted / **mounted but failed to load**).
280
322
 
281
323
  Clicking a row opens a dialog rather than expanding it in place — inline
282
324
  expansion reflows every row below and made the card look like two stacked cards.
@@ -288,7 +330,7 @@ The same operations are exposed to the agent as the `mcp_manage` tool, so the
288
330
  page and the agent share one implementation and cannot diverge:
289
331
 
290
332
  ```
291
- action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
333
+ action: scan | probe | retry | toggle | apply | remove | sync | snippet | reveal | history
292
334
  ```
293
335
 
294
336
  ## Configuration
@@ -318,10 +360,11 @@ one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
318
360
  their literals appears anywhere in the state payload; the page shows only key
319
361
  names, and the write path refuses a masked value.
320
362
 
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:
363
+ Fourteen test suites under `scripts/` cover the editors, the path layer, the write
364
+ path, the DSH adapter, the patch layer, the mount switch, runtime load-state
365
+ detection, the mount-name mapping for non-ASCII server names, redaction and the
366
+ masked-write guard, plus an end-to-end pass that asserts no credential literal
367
+ reaches the client payload:
325
368
 
326
369
  ```
327
370
  node scripts/test-surgical.mjs 17 passing
@@ -330,6 +373,7 @@ node scripts/test-dsh.mjs 16 passing
330
373
  node scripts/test-dsh-patch.mjs 15 passing
331
374
  node scripts/test-dsh-e2e.mjs 8 passing
332
375
  node scripts/test-toggle.mjs 16 passing
376
+ node scripts/test-load.mjs 15 passing
333
377
  node scripts/test-redact.mjs 14 passing
334
378
  node scripts/test-masked-write.mjs 5 passing
335
379
  node scripts/test-client-load.mjs 28 passing
@@ -352,6 +396,12 @@ node scripts/test-release.mjs 11 passing
352
396
  `disabled: true` override the plugin manager writes, and stays in the file.
353
397
  - **Only DSH is switchable.** The other tools' files are read and written
354
398
  explicitly, never toggled — see the section above.
399
+ - **Load state only covers DSH's own connections.** Whether a server in another
400
+ tool actually starts is knowable only by pressing probe; those tools are not in
401
+ this harness's tool registry and there is nowhere else to read it from.
402
+ - **A server with no tools is reported as a failed load.** The judgement is the
403
+ tool registry, so a connection that succeeds but exposes zero tools looks the
404
+ same as one that never connected. Such servers are rare, but they exist.
355
405
  - **Continue is read-only** (YAML array shape); editing it is planned.
356
406
  - **Secrets are not migrated to a vault.** The plugin masks and warns; moving
357
407
  credentials into `${env:...}` references stays the user's decision.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **中文** · [English](README.en.md)
4
4
 
5
- 一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来。
5
+ 一个设置页,管理散落在本机各个 AI 开发工具里的 MCP 服务器 —— 每个配置文件一张卡片,显示它声明了什么;每个服务器一个开关,把它挂载进 DeepSeek Harness;再加一次真连探活,看它到底能不能起来;更重要的是,页面直接告诉你它在 Harness 里**到底加载成功没有**,失败了可以就地重试。
6
6
 
7
7
  入口:DeepSeek Harness → **设置 → MCP**(位于「技能管理」和「插件市场」之间)。
8
8
 
@@ -17,11 +17,11 @@
17
17
  直接说「安装 @oh-agent/dsh-mcp-manager」,等价于:
18
18
 
19
19
  ```
20
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.2"
20
+ plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.4"
21
21
  ```
22
22
 
23
23
  `target` 也可以填一个绝对目录 —— 本地源码目录,或者
24
- [v0.1.2 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
24
+ [v0.1.4 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
25
25
  `dsh-mcp-manager.tgz` 解出来的目录。
26
26
 
27
27
  **不要**手动 `pnpm add` 进 profile,也不要手改 profile 的 `package.json` 或
@@ -145,11 +145,32 @@ client.js (设置页) index.js (宿主入口)
145
145
 
146
146
  因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
147
147
 
148
+ ### 挂上了 ≠ 加载成功了
149
+
150
+ 这是最容易撒谎的一格。行存在于 `cordis.patch.yml` 里,只证明它**被挂载**了;它一点都不能证明 `npx` 起没起来、握手成没成功。
151
+
152
+ `@deepseek-ai/dsh-mcp-client` 的默认 `failOnStartupError: false` 让这件事更隐蔽:`apply()` 会等 `connection.ready`,连接失败时**只是返回**,不抛错。于是插件管理页里那一行的 `fiberPhase` 照样是 `active` —— 你看到的每一项都是绿的,工具却一个都没有。把 Node 切到 14 再启动 DSH 就是这个现象:老 Node 跑不了新语法的 MCP 服务器,进程立刻退出,界面上却什么异常都没有。
153
+
154
+ 所以本插件不看 `fiberPhase`,改看**工具注册表**:一个连接成功的 MCP 服务器,必然在 `tools` 里注册了 `mcp__<serverName>__<工具名>` 形式的工具。有就是 `loaded`,一条都没有就是 `failed`,这才是真话。
155
+
156
+ | 页面显示 | 含义 |
157
+ | --- | --- |
158
+ | `已加载 · N 个工具` | 连接成功,工具已注册,agent 现在就能调 |
159
+ | `已挂载但加载失败` + 「重试」 | 行挂上了,但一个工具都没注册 —— 进程没起来或握手失败 |
160
+ | `正在激活` | 刚写完补丁行,Loader 还没重排完 |
161
+ | `加载状态未知` | 读不到工具注册表(老版本宿主),此时**不下结论**,绝不误判成失败 |
162
+
163
+ 拿不到注册表时宁可显示「未知」也不显示「失败」:把一台健康的服务器标成坏的,比不标更糟。
164
+
165
+ 「重试」走的是 `hmr.reconcileProfile([entryId])`(在 `hmr.runExclusive` 里串行执行),它会让 Loader 重新导入那些失败的行并判定激活结果 —— 连接失败之后的退避重试是 mcp-client 自己的事,可它一旦放弃就什么都不注册、只等一次重载,而设置页给不了这次重载,所以必须由我们来触发。重连不是瞬时的(起进程 + 握手),因此按钮点完 2.5 秒后页面会自己再读一次状态。
166
+
167
+ `scripts/test-load.mjs` 钉住了这套判定,重点是 `unknown` / `failed` / `loaded` 三者的边界,以及按名字归属工具时不能张冠李戴。
168
+
148
169
  ### 挂载名不总是显示名
149
170
 
150
- `@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 这台机器上有十几个叫 `表单管理系统 - API 文档` 之类,`Figma AI Bridge` 带空格。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
171
+ `@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 大量中文名形如 `示例服务器 - API 文档`,也有带空格的英文名形如 `My Bridge`。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
151
172
 
152
- 因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `表单管理系统 - API 文档` → `API-c2f500c4`,`Figma AI Bridge` → `Figma-AI-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
173
+ 因此 `canonical.js` 导出 `dshMountName(name)`:先 sanitize,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 —— `示例服务器 - API 文档` → `API-0aac0ee2`,`My Bridge` → `My-Bridge`。它是纯函数且全域的,所以写入路径、扫描、开关、移除都从同一输入算出同一字符串,不需要在任何地方存映射表。
153
174
 
154
175
  扫描和页面处理的后果:
155
176
 
@@ -161,18 +182,18 @@ client.js (设置页) index.js (宿主入口)
161
182
 
162
183
  ## 怎么用
163
184
 
164
- 页面布局和它旁边的技能页一致:标题行加操作按钮,四格汇总条(服务器 · 文件 · 已挂载进 DSH · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
185
+ 页面布局和它旁边的技能页一致:标题行加操作按钮,五格汇总条(服务器 · 文件 · 已挂载进 DSH · 已加载 · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
165
186
 
166
- 每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、有多少工具共用这份定义、探活结果,以及挂载开关。
187
+ 每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、**加载状态**、有多少工具共用这份定义、探活结果,以及挂载开关。
167
188
 
168
- 筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按挂载状态(已挂载 / 未挂载)。
189
+ 筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按状态(已挂载 / 未挂载 / **已挂载但加载失败**)。
169
190
 
170
191
  点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
171
192
 
172
193
  同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
173
194
 
174
195
  ```
175
- action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
196
+ action: scan | probe | retry | toggle | apply | remove | sync | snippet | reveal | history
176
197
  ```
177
198
 
178
199
  ## 配置
@@ -194,7 +215,7 @@ action: scan | probe | toggle | apply | remove | sync | snippet | reveal | histo
194
215
 
195
216
  磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
196
217
 
197
- `scripts/` 下十三个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护、发布元数据一致性,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
218
+ `scripts/` 下十四个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、运行时加载状态判定、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护、发布元数据一致性,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
198
219
 
199
220
  ```
200
221
  node scripts/test-surgical.mjs 17 passing
@@ -203,6 +224,7 @@ node scripts/test-dsh.mjs 16 passing
203
224
  node scripts/test-dsh-patch.mjs 15 passing
204
225
  node scripts/test-dsh-e2e.mjs 8 passing
205
226
  node scripts/test-toggle.mjs 16 passing
227
+ node scripts/test-load.mjs 15 passing
206
228
  node scripts/test-redact.mjs 14 passing
207
229
  node scripts/test-masked-write.mjs 5 passing
208
230
  node scripts/test-client-load.mjs 28 passing
@@ -218,6 +240,8 @@ node scripts/test-release.mjs 11 passing
218
240
  - **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
219
241
  - **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
220
242
  - **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
243
+ - **加载状态只能看见 DSH 自己的连接。** 别的工具的服务器有没有真正跑起来,只有点「探活」才知道;它们不在这个 Harness 的工具注册表里,没别的地方能读到。
244
+ - **没有工具的服务会被报成「加载失败」。** 判定依据是工具注册表,所以一个连接完全成功、但本身不提供任何工具的服务器,看起来和连不上是一样的。这种服务器很少,但确实存在。
221
245
  - **Continue 只读**(YAML 数组形态);编辑在计划中。
222
246
  - **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
223
247
  - **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
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/client.js CHANGED
@@ -50,12 +50,14 @@ window.__ModuleLoader__.load({
50
50
  'summary.servers': 'servers',
51
51
  'summary.files': 'files',
52
52
  'summary.mounted': 'mounted in DSH',
53
+ 'summary.loaded': 'loaded',
53
54
  'summary.secrets': 'literal credentials',
54
55
  'filter.search': 'Filter by name, command or path',
55
56
  'filter.toolAll': 'All tools',
56
57
  'filter.statusAll': 'All servers',
57
58
  'filter.statusMounted': 'Mounted in DSH',
58
59
  'filter.statusUnmounted': 'Not mounted',
60
+ 'filter.statusFailed': 'Mounted but failed to load',
59
61
  'label.project': 'project',
60
62
  'label.global': 'global',
61
63
  'label.profile': 'profile',
@@ -64,6 +66,10 @@ window.__ModuleLoader__.load({
64
66
  'label.mounted': 'mounted',
65
67
  'label.off': 'off',
66
68
  'label.shared': 'in {count} files',
69
+ 'label.loaded': 'loaded',
70
+ 'label.loadFailed': 'failed to load',
71
+ 'label.loadUnknown': 'load unknown',
72
+ 'btn.retry': 'Retry',
67
73
  'switch.title': 'Mount in DeepSeek Harness (this never edits {client})',
68
74
  'detail.targets': 'Declared in',
69
75
  'detail.definition': 'Definition',
@@ -75,6 +81,14 @@ window.__ModuleLoader__.load({
75
81
  'probe.ok': 'healthy · {tools} tools · {ms}ms',
76
82
  'probe.failed': 'failed',
77
83
  'probe.tools': '{count} tools',
84
+ 'load.loaded': 'loaded · {count} tools',
85
+ 'load.failed': 'failed to load',
86
+ 'load.failedTitle': 'Mounted in DeepSeek Harness, but no tool was ever registered — the connection did not succeed. A server that declares no tools looks the same, so run a test to confirm.',
87
+ 'load.unknown': 'load unknown',
88
+ 'load.unknownTitle': 'The tool registry is not available, so whether this server connected cannot be checked from here.',
89
+ 'toast.retried': 'Reconnected “{name}” — {count} tools are live now.',
90
+ 'toast.retryFailed': '“{name}” is still not loaded. Run a test to see why the process will not start.',
91
+ 'error.retry': 'DeepSeek Harness could not re-run this connection.',
78
92
  'banner.secrets': '{count} server(s) store credentials as literal text in a configuration file that may be committed.',
79
93
  'banner.parse': '{count} file(s) could not be parsed.',
80
94
  'confirm.sync': 'Copy “{name}” into {count} other tool(s)?',
@@ -115,12 +129,14 @@ window.__ModuleLoader__.load({
115
129
  'summary.servers': '个服务器',
116
130
  'summary.files': '个文件',
117
131
  'summary.mounted': '已挂载到 DSH',
132
+ 'summary.loaded': '已加载',
118
133
  'summary.secrets': '处明文凭据',
119
134
  'filter.search': '按名称、命令或路径筛选',
120
135
  'filter.toolAll': '全部工具',
121
136
  'filter.statusAll': '全部服务器',
122
137
  'filter.statusMounted': '已挂载到 DSH',
123
138
  'filter.statusUnmounted': '未挂载',
139
+ 'filter.statusFailed': '已挂载但加载失败',
124
140
  'label.project': '项目',
125
141
  'label.global': '全局',
126
142
  'label.profile': 'profile',
@@ -129,6 +145,10 @@ window.__ModuleLoader__.load({
129
145
  'label.mounted': '已挂载',
130
146
  'label.off': '未启用',
131
147
  'label.shared': '出现在 {count} 个文件',
148
+ 'label.loaded': '已加载',
149
+ 'label.loadFailed': '加载失败',
150
+ 'label.loadUnknown': '加载状态未知',
151
+ 'btn.retry': '重试',
132
152
  'switch.title': '挂载到 DeepSeek Harness(不会改动 {client} 本身)',
133
153
  'detail.targets': '声明位置',
134
154
  'detail.definition': '定义',
@@ -140,6 +160,14 @@ window.__ModuleLoader__.load({
140
160
  'probe.ok': '正常 · {tools} 个工具 · {ms}ms',
141
161
  'probe.failed': '失败',
142
162
  'probe.tools': '{count} 个工具',
163
+ 'load.loaded': '已加载 · {count} 个工具',
164
+ 'load.failed': '加载失败',
165
+ 'load.failedTitle': '已挂载到 DeepSeek Harness,但一个工具都没注册上 —— 说明连接没有成功。声明了零个工具的服务器看起来一样,建议点“测试”确认。',
166
+ 'load.unknown': '加载状态未知',
167
+ 'load.unknownTitle': '当前取不到工具注册表,无法从这里判断该服务器是否连上。',
168
+ 'toast.retried': '已重连 “{name}” —— 现有 {count} 个工具可用。',
169
+ 'toast.retryFailed': '“{name}” 仍未加载。点“测试”可以看清进程为什么起不来。',
170
+ 'error.retry': 'DeepSeek Harness 无法重新发起这次连接。',
143
171
  'banner.secrets': '有 {count} 个服务器把凭据以明文保存在可能被提交的配置文件里。',
144
172
  'banner.parse': '有 {count} 个文件无法解析。',
145
173
  'confirm.sync': '把 “{name}” 复制到 {count} 个其他工具?',
@@ -245,17 +273,16 @@ window.__ModuleLoader__.load({
245
273
  .mcpm-btn.primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: #fff; }
246
274
  .mcpm-btn.primary:hover:not(:disabled) { border-color: var(--dsw-alias-brand-primary); opacity: .88; }
247
275
 
248
- .mcpm-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden;
276
+ /* auto-fit wraps the stats instead of needing one breakpoint per column count,
277
+ which matters now that there are five of them rather than four. */
278
+ .mcpm-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); overflow: hidden;
249
279
  border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px); background: var(--dsw-alias-bg-layer-1); }
250
280
  .mcpm-stat { min-width: 0; overflow: hidden; padding: 4px 10px; border-right: 0.5px solid var(--dsw-alias-border-l1);
251
281
  font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); text-overflow: ellipsis; white-space: nowrap; }
252
- .mcpm-stat:last-child { border-right: 0; }
253
282
  .mcpm-stat strong { margin-right: 4px; color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 650; }
254
- @container (max-width: 620px) {
255
- .mcpm-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
256
- .mcpm-stat:nth-child(2) { border-right: 0; }
257
- .mcpm-stat:nth-child(-n+2) { border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
258
- }
283
+ /* A stat that reports a failure has to read as one at a glance. */
284
+ .mcpm-stat-bad { color: var(--dsw-alias-state-error-primary); }
285
+ .mcpm-stat-bad strong { color: var(--dsw-alias-state-error-primary); }
259
286
  @container (max-width: 380px) {
260
287
  .mcpm-summary { grid-template-columns: 1fr; }
261
288
  .mcpm-stat { border-right: 0; border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
@@ -541,6 +568,8 @@ window.__ModuleLoader__.load({
541
568
  const [expanded, setExpanded] = useState({});
542
569
  const [probes, setProbes] = useState({});
543
570
  const [probing, setProbing] = useState({});
571
+ /* Keyed by server name so several failed rows can be retried independently. */
572
+ const [retrying, setRetrying] = useState({});
544
573
  const [busy, setBusy] = useState(false);
545
574
  const [toast, setToast] = useState(null);
546
575
  const [snippet, setSnippet] = useState(null);
@@ -592,6 +621,9 @@ window.__ModuleLoader__.load({
592
621
  * from: one name can be declared in five tools while being mounted in
593
622
  * DeepSeek Harness exactly once. Indexing it here keeps every row's
594
623
  * switch consistent with the DSH card.
624
+ *
625
+ * `load` is carried alongside because a mount is only half the answer:
626
+ * the row is mounted and still may never have connected.
595
627
  */
596
628
  const mountIndex = useMemo(() => {
597
629
  const index = new Map();
@@ -603,6 +635,7 @@ window.__ModuleLoader__.load({
603
635
  enabled: dsh ? dsh.enabled !== false : false,
604
636
  pending: dsh?.pending === true,
605
637
  entryId: dsh?.entryId ?? null,
638
+ load: server.load ?? dsh?.load ?? null,
606
639
  node: server,
607
640
  });
608
641
  }
@@ -637,6 +670,9 @@ window.__ModuleLoader__.load({
637
670
  const isMounted = Boolean(mount?.mounted && mount.enabled);
638
671
  if (statusFilter === 'mounted' && !isMounted) continue;
639
672
  if (statusFilter === 'unmounted' && isMounted) continue;
673
+ /* The whole point of the filter: a mounted row whose connection never
674
+ came up. Servers the registry cannot judge are not claimed as bad. */
675
+ if (statusFilter === 'failed' && mount?.load?.status !== 'failed') continue;
640
676
  if (needle) {
641
677
  const haystack = [
642
678
  item.name,
@@ -667,6 +703,26 @@ window.__ModuleLoader__.load({
667
703
  [mountIndex],
668
704
  );
669
705
 
706
+ /*
707
+ * Counted from the server list rather than from the filtered view: the
708
+ * headline numbers describe the machine, not the current search.
709
+ */
710
+ const loadCounts = useMemo(() => {
711
+ const counts = { loaded: 0, failed: 0, off: 0, pending: 0, unknown: 0 };
712
+ for (const item of mountIndex.values()) {
713
+ const status = item.load?.status;
714
+ if (!status || status === 'absent') continue;
715
+ if (status in counts) counts[status] += 1;
716
+ }
717
+ return counts;
718
+ }, [mountIndex]);
719
+
720
+ /*
721
+ * Opt-in, not opt-out: an older host snapshot has no `loadSummary` at all,
722
+ * and that must read as "cannot tell" rather than as "registry is fine".
723
+ */
724
+ const registryAvailable = state?.loadSummary?.registry === true;
725
+
670
726
  const run = async (action, body, success, after) => {
671
727
  setBusy(true);
672
728
  setError(null);
@@ -736,6 +792,39 @@ window.__ModuleLoader__.load({
736
792
  if (!result) flash(t('error.mount'));
737
793
  };
738
794
 
795
+ /*
796
+ * Retry a server that is mounted but never connected.
797
+ *
798
+ * The failure is often environmental and temporary — the wrong Node was on
799
+ * PATH when the Host booted, a package was still downloading — so the fix
800
+ * is simply to run the connection again. That is a Host-side operation
801
+ * (a profile reconcile), not a config edit.
802
+ */
803
+ const retryLoad = async (name) => {
804
+ setRetrying((current) => ({ ...current, [name]: true }));
805
+ try {
806
+ const data = await run('retry', { name });
807
+ if (!data) {
808
+ flash(t('error.retry'));
809
+ return;
810
+ }
811
+ if (data.loaded) flash(t('toast.retried', { name, count: data.load?.toolCount ?? 0 }));
812
+ else flash(t('toast.retryFailed', { name }));
813
+ /*
814
+ * A reconnect is not instant: the process still has to spawn and do its
815
+ * initialize handshake, so the snapshot in the response can be one beat
816
+ * too early. Re-read once after it has had a moment.
817
+ */
818
+ setTimeout(() => { if (mounted.current) void load(reveal); }, 2500);
819
+ } finally {
820
+ setRetrying((current) => {
821
+ const next = { ...current };
822
+ delete next[name];
823
+ return next;
824
+ });
825
+ }
826
+ };
827
+
739
828
  const doSync = async (server) => {
740
829
  if (!window.confirm(t('confirm.sync', { name: server.name, count: syncPicks.length }))) return;
741
830
  const data = await run('sync', { name: server.name, server: server.canonical, targets: syncPicks });
@@ -791,10 +880,23 @@ window.__ModuleLoader__.load({
791
880
  h('p', { className: 'mcpm-desc', key: 'desc' }, t('desc')),
792
881
  ]);
793
882
 
883
+ /*
884
+ * "Mounted" alone is a lie the old page told: a row can be mounted and its
885
+ * process fail to start, which is exactly the case this stat exists to
886
+ * make visible. Loaded and failed are counted separately beside it.
887
+ */
794
888
  const summary = state ? h('div', { className: 'mcpm-summary', key: 'summary' }, [
795
889
  h('div', { className: 'mcpm-stat', key: 'servers' }, [h('strong', { key: 'v' }, String(state.servers.length)), t('summary.servers')]),
796
890
  h('div', { className: 'mcpm-stat', key: 'files' }, [h('strong', { key: 'v' }, String(sources.length)), t('summary.files')]),
797
891
  h('div', { className: 'mcpm-stat', key: 'mounted' }, [h('strong', { key: 'v' }, String(mountedCount)), t('summary.mounted')]),
892
+ h('div', {
893
+ className: `mcpm-stat${loadCounts.failed > 0 ? ' mcpm-stat-bad' : ''}`,
894
+ key: 'loaded',
895
+ title: loadCounts.failed > 0 ? t('load.failedTitle') : undefined,
896
+ }, [
897
+ h('strong', { key: 'v' }, registryAvailable ? `${loadCounts.loaded}/${mountedCount}` : '—'),
898
+ t('summary.loaded'),
899
+ ]),
798
900
  h('div', { className: 'mcpm-stat', key: 'secrets' }, [h('strong', { key: 'v' }, String(state.literalSecrets.length)), t('summary.secrets')]),
799
901
  ]) : null;
800
902
 
@@ -831,6 +933,7 @@ window.__ModuleLoader__.load({
831
933
  h('option', { key: 'all', value: 'all' }, t('filter.statusAll')),
832
934
  h('option', { key: 'mounted', value: 'mounted' }, t('filter.statusMounted')),
833
935
  h('option', { key: 'unmounted', value: 'unmounted' }, t('filter.statusUnmounted')),
936
+ h('option', { key: 'failed', value: 'failed' }, t('filter.statusFailed')),
834
937
  ]),
835
938
  ]) : null;
836
939
 
@@ -857,6 +960,40 @@ window.__ModuleLoader__.load({
857
960
  ]);
858
961
  };
859
962
 
963
+ /*
964
+ * The load badge, and the retry that belongs beside it.
965
+ *
966
+ * Only a mounted-and-enabled row can be loaded, so nothing is shown
967
+ * otherwise — an unmounted server is not "failed", it is simply absent.
968
+ * `unknown` is rendered as its own neutral state rather than as a failure,
969
+ * because a scan without the tool registry must not accuse a healthy server.
970
+ */
971
+ const loadTag = (mount, name) => {
972
+ const load = mount?.load;
973
+ if (!mount?.mounted || !mount.enabled || !load) return null;
974
+ const status = load.status;
975
+ if (status === 'off' || status === 'absent') return null;
976
+ const parts = [];
977
+ if (status === 'loaded') {
978
+ parts.push(h('span', { className: 'mcpm-tag ok', key: 'ld' }, t('load.loaded', { count: load.toolCount ?? 0 })));
979
+ } else if (status === 'failed') {
980
+ parts.push(h('span', { className: 'mcpm-tag err', key: 'ld', title: t('load.failedTitle') }, t('load.failed')));
981
+ parts.push(h('button', {
982
+ key: 'rt',
983
+ type: 'button',
984
+ className: 'mcpm-btn sm',
985
+ disabled: busy || (retrying[name] === true),
986
+ title: t('btn.retry'),
987
+ onClick: () => retryLoad(name),
988
+ }, retrying[name] ? h('span', { className: 'mcpm-spin', key: 's' }) : t('btn.retry')));
989
+ } else if (status === 'pending') {
990
+ parts.push(h('span', { className: 'mcpm-tag', key: 'ld' }, t('label.pending')));
991
+ } else {
992
+ parts.push(h('span', { className: 'mcpm-tag', key: 'ld', title: t('load.unknownTitle') }, t('load.unknown')));
993
+ }
994
+ return parts;
995
+ };
996
+
860
997
  const rowFor = (source, item) => {
861
998
  const mount = mountIndex.get(item.name);
862
999
  const node = mount?.node ?? null;
@@ -880,6 +1017,7 @@ window.__ModuleLoader__.load({
880
1017
  shared > 1 ? h('span', { className: 'mcpm-tag', key: 'sh', title: node?.clients?.join(', ') ?? '' }, t('label.shared', { count: shared })) : null,
881
1018
  mount?.pending ? h('span', { className: 'mcpm-tag warn', key: 'pd' }, t('label.pending')) : null,
882
1019
  isDsh && !checked ? h('span', { className: 'mcpm-tag', key: 'off' }, t('label.off')) : null,
1020
+ loadTag(mount, item.name),
883
1021
  probeTag(item.name),
884
1022
  h(MountSwitch, {
885
1023
  key: 'sw',
package/lib/index.js CHANGED
@@ -68,6 +68,11 @@ const STATUS_BY_CODE = new Map([
68
68
  ['error.backup.missing', 404],
69
69
  ['error.route.missing', 404],
70
70
  ['error.action.unknown', 400],
71
+ ['error.dsh.notMounted', 409],
72
+ ['error.dsh.pending', 409],
73
+ ['error.dsh.noEntry', 409],
74
+ ['error.dsh.noHmr', 501],
75
+ ['error.dsh.retry', 500],
71
76
  ]);
72
77
 
73
78
  function statusFor(result) {
@@ -144,9 +149,16 @@ export function apply(ctx, config = {}) {
144
149
 
145
150
  const configEditor = () => (typeof ctx.get === 'function' ? ctx.get('configEditor') : undefined);
146
151
 
152
+ /* The tool registry is the only truthful source of "did the connection
153
+ succeed": a connected server has registered `mcp__<mount>__<tool>` names,
154
+ while `@deepseek-ai/dsh-mcp-client` swallows a failed connect. Read lazily
155
+ so a profile without the service still scans (statuses become `unknown`). */
156
+ const toolRegistry = () => (typeof ctx.get === 'function' ? ctx.get('tools') : undefined);
157
+
147
158
  const projectRoots = () => (options.includeProjectScope ? registeredWorkspaceRoots(ctx) : []);
148
159
 
149
- const snapshot = (reveal = false) => scanAll({ store, configEditor: configEditor(), projectRoots: projectRoots(), reveal, profileDir });
160
+ const snapshot = (reveal = false) =>
161
+ scanAll({ store, configEditor: configEditor(), projectRoots: projectRoots(), reveal, profileDir, tools: toolRegistry() });
150
162
 
151
163
  /* ---------------------------------------------------------- operations -- */
152
164
 
@@ -321,6 +333,63 @@ export function apply(ctx, config = {}) {
321
333
  return store.restore(body.backup, body.path);
322
334
  }
323
335
 
336
+ /**
337
+ * Retry a mounted server that never connected.
338
+ *
339
+ * Nothing in the config is wrong when a connection fails — the process was
340
+ * simply not there yet (wrong Node version, a package that had not finished
341
+ * downloading, a laptop that had just woken up). The mcp-client keeps its own
342
+ * backoff timer for that, but once it gives up it registers nothing and waits
343
+ * for a reload that a settings page has no way to deliver.
344
+ *
345
+ * The supported lever is `hmr.reconcileProfile(requiredIds)`: it recomposes the
346
+ * profile, re-imports the entry, and — because the id is listed as required —
347
+ * reports its inactivity instead of silently tolerating it. That re-runs the
348
+ * connection, which is exactly the retry the user asked for. Serialized through
349
+ * `runExclusive` so it cannot race an automatic reload.
350
+ */
351
+ async function opRetry(body) {
352
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
353
+ if (!name) return { ok: false, code: 'error.input', error: 'name is required' };
354
+
355
+ const state = snapshot(false);
356
+ const node = state.servers.find((item) => item.name === name);
357
+ if (!node) return { ok: false, code: 'error.server.missing', error: `unknown server ${name}` };
358
+ const target = node.targets.find((item) => item.clientId === 'dsh');
359
+ if (!target) return { ok: false, code: 'error.dsh.notMounted', error: `${name} is not mounted in DeepSeek Harness` };
360
+ if (target.pending === true) return { ok: false, code: 'error.dsh.pending', error: `${name} is still being mounted; wait for it to appear first` };
361
+ if (!target.entryId) return { ok: false, code: 'error.dsh.noEntry', error: `${name} has no Loader entry id; reload the plugin instead` };
362
+
363
+ const hmr = typeof ctx.get === 'function' ? ctx.get('hmr') : undefined;
364
+ if (!hmr || typeof hmr.reconcileProfile !== 'function') {
365
+ return { ok: false, code: 'error.dsh.noHmr', error: 'the hot-reload service is not available, so the connection cannot be re-run' };
366
+ }
367
+
368
+ const before = node.load ?? null;
369
+ try {
370
+ const run = () => hmr.reconcileProfile([target.entryId]);
371
+ const reconciled = typeof hmr.runExclusive === 'function' ? await hmr.runExclusive(run) : await run();
372
+ const after = snapshot(false).servers.find((item) => item.name === name);
373
+ const load = after?.load ?? null;
374
+ /* A reconcile that succeeds only proves the profile recomposed. Whether the
375
+ connection came back is decided by the registry, read again after it. */
376
+ return {
377
+ ok: true,
378
+ name,
379
+ entryId: target.entryId,
380
+ before,
381
+ load,
382
+ loaded: load?.status === 'loaded',
383
+ warnings: Array.isArray(reconciled?.warnings) ? reconciled.warnings : [],
384
+ pending: Array.isArray(reconciled?.pending) ? reconciled.pending.map((item) => item.reason) : [],
385
+ restartReason: reconciled?.restartReason ?? null,
386
+ state: snapshot(false),
387
+ };
388
+ } catch (error) {
389
+ return { ok: false, code: 'error.dsh.retry', error: String(error && error.message ? error.message : error) };
390
+ }
391
+ }
392
+
324
393
  const dispatch = async (action, body = {}) => {
325
394
  switch (action) {
326
395
  case 'scan': return opScan(body);
@@ -332,6 +401,7 @@ export function apply(ctx, config = {}) {
332
401
  case 'reveal': return opReveal(body);
333
402
  case 'toggle': return opToggle(body);
334
403
  case 'restore': return opRestore(body);
404
+ case 'retry': return opRetry(body);
335
405
  case 'history': return { ok: true, writes: store.recentWrites(100), backups: store.listBackups(100) };
336
406
  default: return { ok: false, code: 'error.action.unknown', error: `unknown action ${action}` };
337
407
  }
@@ -402,14 +472,15 @@ export function apply(ctx, config = {}) {
402
472
  description: [
403
473
  'Inspect and manage Model Context Protocol servers across the local AI development tools installed on this machine',
404
474
  '(DeepSeek Harness, Claude Code, Cursor, VS Code, Roo Code, OpenCode, Trae, Trae CN, Qoder, Gemini CLI, Zed and others).',
405
- 'Use action "scan" to see every server and which tools it is configured in, "probe" to check whether a server actually starts and lists tools,',
475
+ 'Use action "scan" to see every server, which tools it is configured in, and whether it actually LOADED in DeepSeek Harness (a mounted row can still be a dead connection),',
476
+ '"probe" to check whether a server actually starts and lists tools, "retry" to re-run the connection of a mounted server that failed to load,',
406
477
  '"toggle" to mount or unmount one server in DeepSeek Harness only (it never edits another tool\'s own config file),',
407
478
  '"sync" to copy one definition to other tools, "apply" to write one definition to one target, "remove" to delete one, and "snippet" to get a YAML row for adding a brand-new DeepSeek Harness server.',
408
479
  ].join(' '),
409
480
  parameters: {
410
481
  type: 'object',
411
482
  properties: {
412
- action: { type: 'string', enum: ['scan', 'probe', 'apply', 'remove', 'sync', 'snippet', 'toggle', 'reveal', 'history'], description: 'Operation to perform.' },
483
+ action: { type: 'string', enum: ['scan', 'probe', 'apply', 'remove', 'sync', 'snippet', 'toggle', 'reveal', 'retry', 'history'], description: 'Operation to perform.' },
413
484
  name: { type: 'string', description: 'Server name, as reported by scan.' },
414
485
  enabled: { type: 'boolean', description: 'For toggle: true mounts the server in DeepSeek Harness, false unmounts it.' },
415
486
  clientId: { type: 'string', description: 'Target client id (see scan output).' },
@@ -439,15 +510,44 @@ export function apply(ctx, config = {}) {
439
510
  : (data.activating ? ' — DeepSeek Harness is activating it; its tools appear in a moment' : '');
440
511
  return [{ type: 'text', text: `${data.name}: ${what}${when}` }];
441
512
  }
513
+ if (data.name && data.load && typeof data.loaded === 'boolean') {
514
+ const before = data.before?.status ?? 'unknown';
515
+ const after = data.load?.status ?? 'unknown';
516
+ const lines = [`${data.name}: reconnect requested (was ${before}, now ${after}${data.load?.toolCount ? `, ${data.load.toolCount} tools` : ''}).`];
517
+ if (data.loaded) lines.push('Its tools are registered and callable.');
518
+ else if (after === 'failed') lines.push('Still no tools registered — run "probe" to see why the process will not start.');
519
+ else lines.push('The connection is still settling; scan again in a moment.');
520
+ for (const warning of data.warnings ?? []) lines.push(`warning: ${warning}`);
521
+ return [{ type: 'text', text: lines.join('\n') }];
522
+ }
442
523
  if (data.clients && data.servers) {
443
524
  const lines = [`${data.servers.length} MCP server(s) across ${data.clients.filter((client) => client.installed).length} installed tool(s).`];
525
+ const summary = data.loadSummary;
526
+ if (summary && summary.mounted > 0) {
527
+ lines.push(summary.registry
528
+ ? `Mounted in DeepSeek Harness: ${summary.mounted} — ${summary.loaded} loaded, ${summary.failed} failed to load${summary.off ? `, ${summary.off} off` : ''}${summary.pending ? `, ${summary.pending} activating` : ''}.`
529
+ : `Mounted in DeepSeek Harness: ${summary.mounted}. The tool registry is unavailable, so load state cannot be checked.`);
530
+ }
444
531
  for (const server of data.servers.slice(0, 40)) {
445
532
  /*
446
533
  * Mount state is the one thing a reader acts on, so it belongs on
447
- * the line rather than in a second pass over the payload.
534
+ * the line rather than in a second pass over the payload. A
535
+ * mounted-but-dead server is exactly what this feature exists to
536
+ * expose, so it says FAILED rather than just "mounted".
448
537
  */
449
538
  const dsh = (server.targets ?? []).find((target) => target.clientId === 'dsh');
450
- const mount = dsh ? (dsh.enabled === false ? ' [dsh: off]' : ' [dsh: mounted]') : '';
539
+ const load = server.load?.status;
540
+ const mount = !dsh
541
+ ? ''
542
+ : dsh.enabled === false
543
+ ? ' [dsh: off]'
544
+ : load === 'loaded'
545
+ ? ` [dsh: loaded, ${server.load.toolCount} tools]`
546
+ : load === 'failed'
547
+ ? ' [dsh: MOUNTED BUT FAILED TO LOAD]'
548
+ : load === 'pending'
549
+ ? ' [dsh: activating]'
550
+ : ' [dsh: mounted, load unknown]';
451
551
  lines.push(`- ${server.name} [${server.transport}]${mount} in: ${server.clients.join(', ') || 'none'}`);
452
552
  }
453
553
  if (data.servers.length > 40) lines.push(`… ${data.servers.length - 40} more`);
package/lib/load.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Runtime load status: did a mounted MCP server actually CONNECT?
3
+ *
4
+ * A row in the profile composition only proves a server is MOUNTED. It says
5
+ * nothing about whether the connection succeeded, because `@deepseek-ai/dsh-mcp-client`
6
+ * treats a failed first connection as a non-event: `apply()` awaits
7
+ * `connection.ready` and, when `failOnStartupError` is false (the default), it
8
+ * merely returns on error. The fiber stays `active`, the plugin manager reports
9
+ * `fiberPhase: 'active'`, and the settings file still lists the server — so
10
+ * "mounted but broken" is invisible everywhere the UI used to look.
11
+ *
12
+ * The one place a successful connection MUST leave a trace is the tool
13
+ * registry: every tool of a connected server is registered as
14
+ * `mcp__<serverName>__<rawName>`. That makes the registry the ground truth, and
15
+ * this module only ever READS it — it spawns nothing and writes nothing.
16
+ *
17
+ * Limitation worth stating plainly: a server that connects successfully but
18
+ * declares zero tools also registers zero names, so it is indistinguishable
19
+ * from a failed connection here. The status message says so instead of
20
+ * pretending to know which one it is, and `probe` answers the question.
21
+ */
22
+
23
+ const PREFIX = 'mcp__';
24
+
25
+ /**
26
+ * Read the live tool names out of the registry.
27
+ *
28
+ * Returns `null` — never an empty array — when the registry is unavailable, so
29
+ * a profile without the `tools` service reports "unknown" rather than accusing
30
+ * every server of having failed.
31
+ */
32
+ export function liveToolNames(tools) {
33
+ if (!tools || typeof tools.schemas !== 'function') return null;
34
+ try {
35
+ const schemas = tools.schemas();
36
+ if (!Array.isArray(schemas)) return null;
37
+ return schemas.map((schema) => schema?.name).filter((name) => typeof name === 'string');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Count registered tools per mounted server name.
45
+ *
46
+ * Matched by longest known mount name rather than by splitting on `__`, because
47
+ * `serverName` legitimately allows underscores: for a server named `a__b` the
48
+ * naive split of `mcp__a__b__tool` would credit server `a`. Only the mount
49
+ * names we actually asked about can match, so longest-first is unambiguous.
50
+ */
51
+ export function indexLiveTools(toolNames, mountNames) {
52
+ const counts = new Map();
53
+ if (!Array.isArray(toolNames) || !toolNames.length) return counts;
54
+ const ordered = [...mountNames].filter((name) => typeof name === 'string' && name).sort((left, right) => right.length - left.length);
55
+ if (ordered.length === 0) return counts;
56
+ for (const name of toolNames) {
57
+ if (typeof name !== 'string' || !name.startsWith(PREFIX)) continue;
58
+ const hit = ordered.find((mount) => name.startsWith(`${PREFIX}${mount}__`));
59
+ if (!hit) continue;
60
+ counts.set(hit, (counts.get(hit) ?? 0) + 1);
61
+ }
62
+ return counts;
63
+ }
64
+
65
+ /** Count the registered tools of one mount name. */
66
+ export function countLiveTools(toolNames, mountName) {
67
+ if (!Array.isArray(toolNames) || typeof mountName !== 'string' || !mountName) return 0;
68
+ const prefix = `${PREFIX}${mountName}__`;
69
+ let total = 0;
70
+ for (const name of toolNames) if (typeof name === 'string' && name.startsWith(prefix)) total += 1;
71
+ return total;
72
+ }
73
+
74
+ /**
75
+ * Turn the observations into one status.
76
+ *
77
+ * absent — not mounted in DeepSeek Harness at all
78
+ * off — mounted but disabled, so nothing is expected to run
79
+ * pending — a patch row the Loader has not reconciled yet
80
+ * loaded — mounted, enabled, and at least one tool is registered
81
+ * failed — mounted and enabled, yet no tool is registered
82
+ * unknown — the tool registry is unavailable, so nothing can be claimed
83
+ */
84
+ export function loadStatus({ mounted, enabled, pending, toolCount }) {
85
+ if (!mounted) return 'absent';
86
+ if (enabled === false) return 'off';
87
+ if (pending === true) return 'pending';
88
+ if (toolCount === null || toolCount === undefined) return 'unknown';
89
+ return toolCount > 0 ? 'loaded' : 'failed';
90
+ }
91
+
92
+ /** Build one `load` record for a mount name. */
93
+ export function loadRecord({ mountName, mounted, enabled, pending, counts, registry }) {
94
+ const toolCount = counts ? (mountName ? counts.get(mountName) ?? 0 : 0) : null;
95
+ const status = loadStatus({ mounted, enabled, pending, toolCount });
96
+ return { mountName: mountName ?? null, status, toolCount: toolCount ?? 0, registry: registry === true };
97
+ }
98
+
99
+ /** True when this status is something the user must act on. */
100
+ export function needsAttention(status) {
101
+ return status === 'failed';
102
+ }
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.2' };
28
+ const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.4' };
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
@@ -13,6 +13,7 @@ import { decodeEntry, definitionHash, dshMountName, redactServer } from './canon
13
13
  import { readContainer, readTomlServers, readYamlServers } from './surgical.js';
14
14
  import { mcpEntries, decodeDshEntry } from './dsh.js';
15
15
  import { readDshPatchServers } from './dsh-patch.js';
16
+ import { indexLiveTools, liveToolNames, loadRecord } from './load.js';
16
17
 
17
18
  const READ_KINDS = new Set(['json', 'toml', 'yaml']);
18
19
 
@@ -80,7 +81,7 @@ function readDsh(configEditor, profileDir) {
80
81
  * A server mounted in the harness lives under `dshMountName(displayName)`,
81
82
  * because `serverName` may only use `[A-Za-z0-9_-]` — a CJK name has to be
82
83
  * 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
84
+ * `API-0aac0ee2` next to `示例服务器 - API 文档`, and the display-named node has
84
85
  * no `dsh` target at all, so its switch reads "off" while the server is in fact
85
86
  * mounted.
86
87
  *
@@ -158,6 +159,10 @@ function foldDshNames(matrix, sources, store) {
158
159
  *
159
160
  * projectRoots — workspace directories whose project-scope files to include
160
161
  * reveal — return credential literals instead of masked values
162
+ * tools — the Harness tool registry, read to tell a CONNECTED server
163
+ * (its `mcp__<mount>__<tool>` names are registered) apart from a
164
+ * mounted-but-dead one. Omitting it yields `unknown`, never a
165
+ * false accusation.
161
166
  *
162
167
  * Two projections are produced from one read, because they answer different
163
168
  * questions and recomputing them separately would let them disagree:
@@ -166,7 +171,7 @@ function foldDshNames(matrix, sources, store) {
166
171
  * servers that file holds. This is what the page groups by, since
167
172
  * "which directory holds which MCP" is a per-file question.
168
173
  */
169
- export function scanAll({ store, configEditor, projectRoots = [], reveal = false, profileDir }) {
174
+ export function scanAll({ store, configEditor, projectRoots = [], reveal = false, profileDir, tools }) {
170
175
  const warnings = [];
171
176
  const clients = [];
172
177
  const matrix = new Map();
@@ -303,6 +308,48 @@ export function scanAll({ store, configEditor, projectRoots = [], reveal = false
303
308
  // BEFORE the servers projection is built: the fold rewrites matrix keys.
304
309
  foldDshNames(matrix, sources, store);
305
310
 
311
+ /* Load status, computed AFTER the fold because it needs the real mount name.
312
+ The registry is read once for the whole scan; `null` means the service is
313
+ absent and every status degrades to `unknown` rather than to `failed`. */
314
+ const registryNames = liveToolNames(tools);
315
+ const registry = registryNames !== null;
316
+ const mountOf = (node) => {
317
+ const target = node.targets.find((item) => item.clientId === 'dsh');
318
+ if (!target) return null;
319
+ return target.mountedAs ?? target.server?.mountName ?? dshMountName(node.name);
320
+ };
321
+ const mountNames = [...matrix.values()].map(mountOf).filter((name) => typeof name === 'string' && name);
322
+ const counts = registry ? indexLiveTools(registryNames, [...new Set(mountNames)]) : null;
323
+
324
+ for (const node of matrix.values()) {
325
+ const mountName = mountOf(node);
326
+ const dshTarget = node.targets.find((item) => item.clientId === 'dsh');
327
+ node.load = loadRecord({
328
+ mountName,
329
+ mounted: Boolean(dshTarget),
330
+ enabled: dshTarget ? dshTarget.enabled !== false : false,
331
+ pending: dshTarget?.pending === true,
332
+ counts,
333
+ registry,
334
+ });
335
+ if (dshTarget) dshTarget.load = node.load;
336
+ }
337
+
338
+ for (const source of sources) {
339
+ if (source.clientId !== 'dsh') continue;
340
+ for (const item of source.servers) {
341
+ const mountName = item.mountName ?? item.name;
342
+ item.load = loadRecord({
343
+ mountName,
344
+ mounted: true,
345
+ enabled: item.enabled !== false,
346
+ pending: item.pending === true,
347
+ counts,
348
+ registry,
349
+ });
350
+ }
351
+ }
352
+
306
353
  const servers = [...matrix.values()].map((node) => {
307
354
  const clientIds = new Set(node.targets.map((target) => target.clientId));
308
355
  const canonical = node.canonical;
@@ -322,6 +369,7 @@ export function scanAll({ store, configEditor, projectRoots = [], reveal = false
322
369
  clients: [...clientIds],
323
370
  targets: node.targets,
324
371
  drift,
372
+ load: node.load,
325
373
  canonical: redactServer(canonical, reveal),
326
374
  };
327
375
  }).sort((a, b) => b.clientCount - a.clientCount || a.name.localeCompare(b.name));
@@ -341,6 +389,16 @@ export function scanAll({ store, configEditor, projectRoots = [], reveal = false
341
389
  }
342
390
  }
343
391
 
392
+ /* Counted here so the page and the agent share one number instead of each
393
+ recomputing it from `servers` with slightly different filters. */
394
+ const loadSummary = { mounted: 0, loaded: 0, failed: 0, off: 0, pending: 0, unknown: 0 };
395
+ for (const node of matrix.values()) {
396
+ const status = node.load?.status;
397
+ if (status === 'absent' || !status) continue;
398
+ loadSummary.mounted += 1;
399
+ if (status in loadSummary) loadSummary[status] += 1;
400
+ }
401
+
344
402
  return {
345
403
  clients,
346
404
  servers,
@@ -348,6 +406,7 @@ export function scanAll({ store, configEditor, projectRoots = [], reveal = false
348
406
  locations,
349
407
  warnings,
350
408
  literalSecrets,
409
+ loadSummary: { ...loadSummary, registry },
351
410
  scannedAt: new Date().toISOString(),
352
411
  };
353
412
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oh-agent/dsh-mcp-manager",
3
- "version": "0.1.2",
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 内挂载/卸载,绝不改动其他工具的配置。",
3
+ "version": "0.1.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, tell you whether it actually loaded in DeepSeek Harness with a retry button, and mount or unmount it without ever editing the other tools' files. / 一个设置页管理本机所有 AI 开发工具的 MCP:按配置文件分组展示、真连探活、明确显示在 DeepSeek Harness 里是否真正加载成功并可重试,只在 Harness 内挂载/卸载,绝不改动其他工具的配置。",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -66,4 +66,4 @@
66
66
  "node": "^22.19.0 || >=24.0.0"
67
67
  }
68
68
  }
69
-
69
+