@oh-agent/dsh-mcp-manager 0.1.3 → 0.1.5
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 +104 -15
- package/README.md +44 -9
- package/lib/client.js +212 -7
- package/lib/index.js +170 -5
- package/lib/load.js +102 -0
- package/lib/probe.js +1 -1
- package/lib/scan.js +60 -1
- package/lib/toggle.js +24 -21
- package/package.json +3 -3
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,
|
|
8
|
-
|
|
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.
|
|
20
|
+
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.5"
|
|
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.
|
|
25
|
+
[v0.1.5 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
|
|
@@ -218,10 +219,23 @@ How the mount works, by case:
|
|
|
218
219
|
|
|
219
220
|
| the server is | the switch does | takes effect |
|
|
220
221
|
| --- | --- | --- |
|
|
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
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 we wrote | deletes the item on off, rewrites it on on | on reconcile; the file returns to its exact previous bytes |
|
|
223
224
|
| an `- insert:` item the user wrote | appends/removes the same `- id: …` / `disabled: true` override the plugin manager itself writes | on reconcile |
|
|
224
225
|
| 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` |
|
|
226
|
+
|
|
227
|
+
The order matters, and it cost a real bug: **the Loader path must come before any
|
|
228
|
+
file edit**. The first row and the rows below it overlap — an `- insert:` item we
|
|
229
|
+
wrote ourselves is, once the Loader has read it, a live entry. An earlier version
|
|
230
|
+
checked "is this row ours?" first and deleted the YAML row, so switching one off
|
|
231
|
+
edited a file that needs a reconcile instead of hot-disposing the running
|
|
232
|
+
instance; worse, switching it back on appended the block to the end of
|
|
233
|
+
`cordis.patch.yml`, so the user's careful ordering was destroyed. Measured once:
|
|
234
|
+
a 15,640-byte file changed SHA and the `mcp-playwright` block moved from the
|
|
235
|
+
middle to the end after a single toggle round. It now tries the Loader first and
|
|
236
|
+
only falls back to the file when no such entry exists at all;
|
|
237
|
+
`scripts/test-toggle.mjs` pins this with "the patch file's bytes are identical
|
|
238
|
+
after a toggle round".
|
|
225
239
|
| not mounted anywhere, and switched off | nothing — already true, no dangling override | — |
|
|
226
240
|
|
|
227
241
|
Our own row is removed before the Loader path is tried: the plugin manager can
|
|
@@ -234,6 +248,73 @@ Because the override is a separate item and not a rewrite, and because a row we
|
|
|
234
248
|
wrote is removed rather than disabled, a full mount → unmount cycle returns
|
|
235
249
|
`cordis.patch.yml` to its exact previous bytes.
|
|
236
250
|
|
|
251
|
+
### Mounted is not loaded
|
|
252
|
+
|
|
253
|
+
This is the field most likely to lie. A row existing in `cordis.patch.yml` proves
|
|
254
|
+
only that it was **mounted**; it says nothing about whether `npx` started or the
|
|
255
|
+
handshake succeeded.
|
|
256
|
+
|
|
257
|
+
`@deepseek-ai/dsh-mcp-client` ships `failOnStartupError: false` by default, which
|
|
258
|
+
buries the failure: `apply()` awaits `connection.ready` and, on error, simply
|
|
259
|
+
returns instead of throwing. So the row's `fiberPhase` in the Plugin Manager
|
|
260
|
+
stays `active` — every indicator is green and there are still no tools. Switch
|
|
261
|
+
Node to 14 and start the harness and this is exactly what you get: the old Node
|
|
262
|
+
runtime cannot run a modern MCP server, the process exits immediately, and
|
|
263
|
+
nothing in the UI reports anything wrong.
|
|
264
|
+
|
|
265
|
+
So this plugin ignores `fiberPhase` and reads the **tool registry** instead. A
|
|
266
|
+
server that connected successfully necessarily registered tools named
|
|
267
|
+
`mcp__<serverName>__<tool>`. Some are present → `loaded`; none are → `failed`.
|
|
268
|
+
That is the part that tells the truth.
|
|
269
|
+
|
|
270
|
+
| What the page shows | What it means |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| `loaded · N tools` | Connection succeeded, tools registered, the agent can call them now |
|
|
273
|
+
| `mounted but failed to load` + Retry | The row is mounted but no tool registered — the process never started, or the handshake failed |
|
|
274
|
+
| a red banner at the top + Retry all | N mounted servers cannot connect; retry every failed one in a single action |
|
|
275
|
+
| `activating` | The patch row was just written; the Loader has not recomposed yet |
|
|
276
|
+
| `load state unknown` | The tool registry cannot be read (older host) — **no conclusion is drawn**, and it is never reported as a failure |
|
|
277
|
+
|
|
278
|
+
Reporting "unknown" rather than "failed" when the registry is unavailable is
|
|
279
|
+
deliberate: marking a healthy server as broken is worse than marking nothing. The
|
|
280
|
+
red banner follows the same rule and only appears when the registry is readable —
|
|
281
|
+
otherwise every row reads `unknown`, and calling that a failure would be a guess.
|
|
282
|
+
|
|
283
|
+
Retry is **not** `hmr.reconcileProfile`. That looks like the right lever and is a
|
|
284
|
+
no-op: it only restarts entries that are **inactive**, while
|
|
285
|
+
`@deepseek-ai/dsh-mcp-client` swallows the connect error under
|
|
286
|
+
`failOnStartupError: false` and leaves the fiber **active**. Measured against a
|
|
287
|
+
dead row: `reconcileProfile` returned success and rebuilt nothing — a retry button
|
|
288
|
+
that lies.
|
|
289
|
+
|
|
290
|
+
The lever that works is `pluginManager.setPluginEnabled(entryId, false)` followed
|
|
291
|
+
by `(entryId, true)`: dispose the dead fiber, then mount it again from scratch.
|
|
292
|
+
Hence three steps:
|
|
293
|
+
|
|
294
|
+
1. `setPluginEnabled(entryId, false)` tears the old connection down. If that call
|
|
295
|
+
itself fails, report `could not dispose the stale connection: <code>` — never
|
|
296
|
+
pretend a retry happened;
|
|
297
|
+
2. `setPluginEnabled(entryId, true)` rebuilds it. If it throws, best-effort
|
|
298
|
+
re-enable, because leaving a server switched off is worse than a retry that
|
|
299
|
+
did not help;
|
|
300
|
+
3. poll the tool registry (`mcp__<mountName>__*`) until tools appear or the
|
|
301
|
+
budget runs out. **The verdict comes from the registry, not the composition**:
|
|
302
|
+
a rebuilt fiber shows up in the composition instantly, but only registered
|
|
303
|
+
tools prove the connection is real.
|
|
304
|
+
|
|
305
|
+
Polling the registry rather than the fiber is the whole point: `active` only says
|
|
306
|
+
"this plugin is loaded", and it stays active when the connection is dead — that is
|
|
307
|
+
exactly why the page used to say "mounted" while nothing worked. Reconnecting is
|
|
308
|
+
not instant (process spawn plus handshake), so the page re-reads state 2.5 seconds
|
|
309
|
+
after the button is pressed, and a retry that still finds no tools reports the
|
|
310
|
+
host's own reason instead of a generic "something went wrong".
|
|
311
|
+
|
|
312
|
+
`scripts/test-load.mjs` pins the verdicts down (the boundary between `unknown`,
|
|
313
|
+
`failed` and `loaded`, plus the name matching that decides which tools belong to
|
|
314
|
+
which server), and `scripts/test-retry.mjs` pins the retry itself: exactly one
|
|
315
|
+
disable followed by one enable, the host's reason surfaced verbatim, and a failed
|
|
316
|
+
rebuild must not leave the row switched off.
|
|
317
|
+
|
|
237
318
|
### A mount name is not always the display name
|
|
238
319
|
|
|
239
320
|
`@deepseek-ai/dsh-mcp-client` requires `serverName` to match
|
|
@@ -265,18 +346,18 @@ the round trip through scan.
|
|
|
265
346
|
## Using it
|
|
266
347
|
|
|
267
348
|
The page is laid out like the skills page it sits next to: a title row with the
|
|
268
|
-
actions, a
|
|
269
|
-
credentials), a filter row, then one bordered card per config file.
|
|
349
|
+
actions, a five-cell summary strip (servers · files · mounted in DSH · loaded ·
|
|
350
|
+
literal credentials), a filter row, then one bordered card per config file.
|
|
270
351
|
|
|
271
352
|
Each card lists the servers that file declares — headed by the tool name, the
|
|
272
353
|
server count and the path (shortened to `%APPDATA%\…` style, full path in the
|
|
273
354
|
tooltip). A card collapses; while a filter is active every card opens, because a
|
|
274
355
|
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
|
|
276
|
-
result, and the mount switch.
|
|
356
|
+
transport, the command or URL, its **load state**, how many tools share the
|
|
357
|
+
definition, its probe result, and the mount switch.
|
|
277
358
|
|
|
278
359
|
The filters are a free-text search (name, command, path) plus two selects: by
|
|
279
|
-
tool, and by
|
|
360
|
+
tool, and by state (mounted / not mounted / **mounted but failed to load**).
|
|
280
361
|
|
|
281
362
|
Clicking a row opens a dialog rather than expanding it in place — inline
|
|
282
363
|
expansion reflows every row below and made the card look like two stacked cards.
|
|
@@ -288,7 +369,7 @@ The same operations are exposed to the agent as the `mcp_manage` tool, so the
|
|
|
288
369
|
page and the agent share one implementation and cannot diverge:
|
|
289
370
|
|
|
290
371
|
```
|
|
291
|
-
action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
|
|
372
|
+
action: scan | probe | retry | toggle | apply | remove | sync | snippet | reveal | history
|
|
292
373
|
```
|
|
293
374
|
|
|
294
375
|
## Configuration
|
|
@@ -318,10 +399,11 @@ one in a command-line flag (`--figma-api-key` in Qoder's `mcp.json`). None of
|
|
|
318
399
|
their literals appears anywhere in the state payload; the page shows only key
|
|
319
400
|
names, and the write path refuses a masked value.
|
|
320
401
|
|
|
321
|
-
|
|
322
|
-
path, the DSH adapter, the patch layer, the mount switch,
|
|
323
|
-
for non-ASCII server names, redaction and the
|
|
324
|
-
end-to-end pass that asserts no credential literal
|
|
402
|
+
Fourteen test suites under `scripts/` cover the editors, the path layer, the write
|
|
403
|
+
path, the DSH adapter, the patch layer, the mount switch, runtime load-state
|
|
404
|
+
detection, the mount-name mapping for non-ASCII server names, redaction and the
|
|
405
|
+
masked-write guard, plus an end-to-end pass that asserts no credential literal
|
|
406
|
+
reaches the client payload:
|
|
325
407
|
|
|
326
408
|
```
|
|
327
409
|
node scripts/test-surgical.mjs 17 passing
|
|
@@ -330,6 +412,7 @@ node scripts/test-dsh.mjs 16 passing
|
|
|
330
412
|
node scripts/test-dsh-patch.mjs 15 passing
|
|
331
413
|
node scripts/test-dsh-e2e.mjs 8 passing
|
|
332
414
|
node scripts/test-toggle.mjs 16 passing
|
|
415
|
+
node scripts/test-load.mjs 15 passing
|
|
333
416
|
node scripts/test-redact.mjs 14 passing
|
|
334
417
|
node scripts/test-masked-write.mjs 5 passing
|
|
335
418
|
node scripts/test-client-load.mjs 28 passing
|
|
@@ -352,6 +435,12 @@ node scripts/test-release.mjs 11 passing
|
|
|
352
435
|
`disabled: true` override the plugin manager writes, and stays in the file.
|
|
353
436
|
- **Only DSH is switchable.** The other tools' files are read and written
|
|
354
437
|
explicitly, never toggled — see the section above.
|
|
438
|
+
- **Load state only covers DSH's own connections.** Whether a server in another
|
|
439
|
+
tool actually starts is knowable only by pressing probe; those tools are not in
|
|
440
|
+
this harness's tool registry and there is nowhere else to read it from.
|
|
441
|
+
- **A server with no tools is reported as a failed load.** The judgement is the
|
|
442
|
+
tool registry, so a connection that succeeds but exposes zero tools looks the
|
|
443
|
+
same as one that never connected. Such servers are rare, but they exist.
|
|
355
444
|
- **Continue is read-only** (YAML array shape); editing it is planned.
|
|
356
445
|
- **Secrets are not migrated to a vault.** The plugin masks and warns; moving
|
|
357
446
|
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.
|
|
20
|
+
plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.5"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
`target` 也可以填一个绝对目录 —— 本地源码目录,或者
|
|
24
|
-
[v0.1.
|
|
24
|
+
[v0.1.5 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` 或
|
|
@@ -135,16 +135,48 @@ client.js (设置页) index.js (宿主入口)
|
|
|
135
135
|
|
|
136
136
|
| 服务器是 | 开关做的事 | 生效时机 |
|
|
137
137
|
| --- | --- | --- |
|
|
138
|
-
| 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
|
|
139
138
|
| 已经是运行中的 `dsh-mcp-client` 行 | `pluginManager.setPluginEnabled(rowId, …)` | 立即 —— 行卸载,其工具从工具列表消失 |
|
|
139
|
+
| 我们自己写的 `- insert:` 项 | 关:删掉该项;开:重写它 | 重排后生效;文件回到精确的原始字节 |
|
|
140
140
|
| 用户自己写的 `- insert:` 项 | 追加/移除插件管理器自己也会写的那种 `- id: …` / `disabled: true` 覆盖项 | 重排后生效 |
|
|
141
141
|
| 只声明在别的工具里,且被打开 | 写一条新的 `- insert:` 行 | Loader 重排后即生效(通常 1–4 秒);操作最多等 8 秒并上报 `live: true`,否则 `activating: true` |
|
|
142
142
|
| 哪里都没挂载,且被关闭 | 什么都不做 —— 本就是事实,不留悬空覆盖项 | — |
|
|
143
143
|
|
|
144
|
+
顺序很重要,这是踩过的坑:**Loader 路径必须排在改文件之前**。上面第一行和下面几行会同时命中 —— 我们自己写过的 `- insert:` 项,一旦被 Loader 读进来就是一条活的 entry。早先的版本先判「这行是我们的就删文件」,结果关一次开关,删掉的是 YAML 行(要重排才生效),而不是热卸载那个正在跑的实例;更糟的是重新打开时该块会被追加到文件末尾,用户精心排过序的 `cordis.patch.yml` 被搬了家。实测过一次:15640 字节的文件在开关一轮之后 SHA 变了、`mcp-playwright` 整块从前面挪到了文件尾。现在先走 Loader,只有在根本不存在这个 entry 时才回落到改文件,`scripts/test-toggle.mjs` 用「开关一轮之后补丁文件字节零变化」钉住了这件事。
|
|
145
|
+
|
|
144
146
|
我们自己写的行会在尝试 Loader 路径之前被移除:插件管理器确实能热卸载一行,但它的做法是在定义旁边追加一条 `disabled: true` 覆盖项,那会让我们的行和这条覆盖项一起永久留在用户的组合文件里。删除 insert 项既是卸载也是清理。
|
|
145
147
|
|
|
146
148
|
因为覆盖项是独立的一项而不是重写,且我们自己写的行是删除而不是禁用,一次完整的挂载 → 卸载循环会把 `cordis.patch.yml` 还原到精确的原始字节。
|
|
147
149
|
|
|
150
|
+
### 挂上了 ≠ 加载成功了
|
|
151
|
+
|
|
152
|
+
这是最容易撒谎的一格。行存在于 `cordis.patch.yml` 里,只证明它**被挂载**了;它一点都不能证明 `npx` 起没起来、握手成没成功。
|
|
153
|
+
|
|
154
|
+
`@deepseek-ai/dsh-mcp-client` 的默认 `failOnStartupError: false` 让这件事更隐蔽:`apply()` 会等 `connection.ready`,连接失败时**只是返回**,不抛错。于是插件管理页里那一行的 `fiberPhase` 照样是 `active` —— 你看到的每一项都是绿的,工具却一个都没有。把 Node 切到 14 再启动 DSH 就是这个现象:老 Node 跑不了新语法的 MCP 服务器,进程立刻退出,界面上却什么异常都没有。
|
|
155
|
+
|
|
156
|
+
所以本插件不看 `fiberPhase`,改看**工具注册表**:一个连接成功的 MCP 服务器,必然在 `tools` 里注册了 `mcp__<serverName>__<工具名>` 形式的工具。有就是 `loaded`,一条都没有就是 `failed`,这才是真话。
|
|
157
|
+
|
|
158
|
+
| 页面显示 | 含义 |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `已加载 · N 个工具` | 连接成功,工具已注册,agent 现在就能调 |
|
|
161
|
+
| `已挂载但加载失败` + 「重试」 | 行挂上了,但一个工具都没注册 —— 进程没起来或握手失败 |
|
|
162
|
+
| 页面顶部红色横幅 + 「全部重试」 | 有 N 个服务器挂上了却连不上,一次把失败的全部重试 |
|
|
163
|
+
| `正在激活` | 刚写完补丁行,Loader 还没重排完 |
|
|
164
|
+
| `加载状态未知` | 读不到工具注册表(老版本宿主),此时**不下结论**,绝不误判成失败 |
|
|
165
|
+
|
|
166
|
+
拿不到注册表时宁可显示「未知」也不显示「失败」:把一台健康的服务器标成坏的,比不标更糟。同理,红色横幅只在注册表可读时才出现 —— 否则每一行都是「未知」,那时候报「失败」纯属猜测。
|
|
167
|
+
|
|
168
|
+
失败行的「重试」**不是** `hmr.reconcileProfile`。那个看着像正解,实际是个假动作:它只重启处于 **inactive** 的行,而 `@deepseek-ai/dsh-mcp-client` 在 `failOnStartupError: false` 下会把连接错误吞掉,fiber 一直是 **active**。拿一条死行实测过:`reconcileProfile` 返回成功,一个连接都没重建 —— 一个会说谎的重试按钮。
|
|
169
|
+
|
|
170
|
+
真正的杠杆是 `pluginManager.setPluginEnabled(entryId, false)` 再 `(entryId, true)`:先 dispose 掉那条死 fiber,再重新走一遍完整的挂载。据此分三步:
|
|
171
|
+
|
|
172
|
+
1. `setPluginEnabled(entryId, false)` 拆掉旧连接 —— 这一步失败就直接报「could not dispose the stale connection: <code>」,不要假装重试过;
|
|
173
|
+
2. `setPluginEnabled(entryId, true)` 重建 —— 抛异常时尽力恢复成「开着」,宁可保留一个连不上的行,也不能把用户的服务悄悄关掉;
|
|
174
|
+
3. 轮询工具注册表(`mcp__<挂载名>__*`)直到出现工具或超时。**判定依据是注册表,不是组合文件**:重建后的 fiber 会立刻出现在组合里,可只有它真注册上工具才算连上了。
|
|
175
|
+
|
|
176
|
+
为什么轮询注册表而不是直接看 fiber:fiber `active` 只说明「这个插件被加载了」,连不上也照样 active,这正是当初「显示已挂载、其实加载失败」的根源。重连不是瞬时的(起进程 + 握手),所以按钮点完 2.5 秒后页面会自己再读一次状态;等不到工具时提示里带的是宿主返回的真实原因,而不是一句「出错了」。
|
|
177
|
+
|
|
178
|
+
`scripts/test-load.mjs` 钉住了判定边界(`unknown` / `failed` / `loaded` 三者不能互相冒充,按名字归属工具也不能张冠李戴),`scripts/test-retry.mjs` 钉住了重试本身:必须是「先关后开」两次调用、失败原因要原样透出、重建失败时不能把行留在关闭状态。
|
|
179
|
+
|
|
148
180
|
### 挂载名不总是显示名
|
|
149
181
|
|
|
150
182
|
`@deepseek-ai/dsh-mcp-client` 要求 `serverName` 匹配 `/^[A-Za-z0-9_-]{1,32}$/`。现实中很多服务器不满足 —— 大量中文名形如 `示例服务器 - API 文档`,也有带空格的英文名形如 `My Bridge`。只用 `sanitizeName` 压一遍,所有中文名都会变成 `API`,第二个挂载就会和第一个撞名,开关就会翻错服务器。
|
|
@@ -161,18 +193,18 @@ client.js (设置页) index.js (宿主入口)
|
|
|
161
193
|
|
|
162
194
|
## 怎么用
|
|
163
195
|
|
|
164
|
-
|
|
196
|
+
页面布局和它旁边的技能页一致:标题行加操作按钮,五格汇总条(服务器 · 文件 · 已挂载进 DSH · 已加载 · 字面凭据),筛选行,然后每个配置文件一张带边框的卡片。
|
|
165
197
|
|
|
166
|
-
每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL
|
|
198
|
+
每张卡片列出该文件声明的服务器 —— 头部是工具名、服务器数量和路径(缩短成 `%APPDATA%\…` 风格,完整路径在 tooltip 里)。卡片可折叠;筛选生效时所有卡片都会展开,因为藏在折叠卡片里的命中会被读成「没有结果」。每行显示传输方式、命令或 URL、**加载状态**、有多少工具共用这份定义、探活结果,以及挂载开关。
|
|
167
199
|
|
|
168
|
-
|
|
200
|
+
筛选是一个自由文本搜索(名字、命令、路径)加两个下拉:按工具、按状态(已挂载 / 未挂载 / **已挂载但加载失败**)。
|
|
169
201
|
|
|
170
202
|
点击一行打开对话框,而不是在原地展开 —— 原地展开会把它下面每一行都重排,让一张卡片看起来像两张叠在一起。对话框列出声明该服务器的每个文件、统一后的定义、一条可直接粘贴进 cordis 组合的 YAML 行,以及「复制到…」用于把定义推到别的工具。
|
|
171
203
|
|
|
172
204
|
同一批操作也以 `mcp_manage` 工具暴露给 agent,所以页面和 agent 共用一份实现,不可能走岔:
|
|
173
205
|
|
|
174
206
|
```
|
|
175
|
-
action: scan | probe | toggle | apply | remove | sync | snippet | reveal | history
|
|
207
|
+
action: scan | probe | retry | toggle | apply | remove | sync | snippet | reveal | history
|
|
176
208
|
```
|
|
177
209
|
|
|
178
210
|
## 配置
|
|
@@ -194,7 +226,7 @@ action: scan | probe | toggle | apply | remove | sync | snippet | reveal | histo
|
|
|
194
226
|
|
|
195
227
|
磁盘上发现 21 处明文凭据 —— 多数在 `env` 映射里,一处在命令行参数里(Qoder `mcp.json` 的 `--figma-api-key`)。它们的字面值没有出现在 state 载荷的任何位置;页面只显示键名,写入路径拒绝被掩码的值。
|
|
196
228
|
|
|
197
|
-
`scripts/`
|
|
229
|
+
`scripts/` 下十四个测试套件覆盖编辑器、路径层、写入路径、DSH 适配器、补丁层、挂载开关、运行时加载状态判定、非 ASCII 服务器名的挂载名映射、脱敏与掩码写入防护、发布元数据一致性,外加一次端到端断言:没有任何凭据字面值进入客户端载荷。
|
|
198
230
|
|
|
199
231
|
```
|
|
200
232
|
node scripts/test-surgical.mjs 17 passing
|
|
@@ -203,6 +235,7 @@ node scripts/test-dsh.mjs 16 passing
|
|
|
203
235
|
node scripts/test-dsh-patch.mjs 15 passing
|
|
204
236
|
node scripts/test-dsh-e2e.mjs 8 passing
|
|
205
237
|
node scripts/test-toggle.mjs 16 passing
|
|
238
|
+
node scripts/test-load.mjs 15 passing
|
|
206
239
|
node scripts/test-redact.mjs 14 passing
|
|
207
240
|
node scripts/test-masked-write.mjs 5 passing
|
|
208
241
|
node scripts/test-client-load.mjs 28 passing
|
|
@@ -218,6 +251,8 @@ node scripts/test-release.mjs 11 passing
|
|
|
218
251
|
- **新挂载的 DSH 服务器依赖 Loader 重排。** 操作最多等 8 秒并上报 `live: true`;窗口过期则上报 `activating: true`,页面稍后重新读取状态。对 Loader 看得见的行,永不索要重启。
|
|
219
252
|
- **不是我们写的行只被关闭,不会被删除。** 删掉用户粘进去的定义等于毁掉他的工作,所以这样的行会得到与插件管理器相同的那种 `disabled: true` 覆盖项,并留在文件里。
|
|
220
253
|
- **只有 DSH 可切换。** 其他工具的文件只被显式读写,从不切换 —— 见上文。
|
|
254
|
+
- **加载状态只能看见 DSH 自己的连接。** 别的工具的服务器有没有真正跑起来,只有点「探活」才知道;它们不在这个 Harness 的工具注册表里,没别的地方能读到。
|
|
255
|
+
- **没有工具的服务会被报成「加载失败」。** 判定依据是工具注册表,所以一个连接完全成功、但本身不提供任何工具的服务器,看起来和连不上是一样的。这种服务器很少,但确实存在。
|
|
221
256
|
- **Continue 只读**(YAML 数组形态);编辑在计划中。
|
|
222
257
|
- **凭据不会被迁移进保险库。** 插件只做掩码和告警;把凭据换成 `${env:...}` 引用仍是用户自己的决定。
|
|
223
258
|
- **不查询 MCP 注册表。** `registry.modelcontextprotocol.io` 明确是预览态且不面向宿主直接消费,所以服务器发现只靠扫描本地文件。
|
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,8 +81,20 @@ 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
|
+
'toast.retryReason': 'Could not reconnect “{name}”: {reason}',
|
|
92
|
+
'toast.retryWarn': '{count} warning(s) from DeepSeek Harness — the connection was rebuilt anyway.',
|
|
93
|
+
'error.retry': 'DeepSeek Harness could not re-run this connection.',
|
|
78
94
|
'banner.secrets': '{count} server(s) store credentials as literal text in a configuration file that may be committed.',
|
|
79
95
|
'banner.parse': '{count} file(s) could not be parsed.',
|
|
96
|
+
'banner.loadFailed': '{count} server(s) are mounted in DeepSeek Harness but failed to connect — no tool is registered from them.',
|
|
97
|
+
'btn.retryAll': 'Retry all',
|
|
80
98
|
'confirm.sync': 'Copy “{name}” into {count} other tool(s)?',
|
|
81
99
|
'confirm.reveal': 'Show credential values in clear text?',
|
|
82
100
|
'error.title': 'Something went wrong',
|
|
@@ -115,12 +133,14 @@ window.__ModuleLoader__.load({
|
|
|
115
133
|
'summary.servers': '个服务器',
|
|
116
134
|
'summary.files': '个文件',
|
|
117
135
|
'summary.mounted': '已挂载到 DSH',
|
|
136
|
+
'summary.loaded': '已加载',
|
|
118
137
|
'summary.secrets': '处明文凭据',
|
|
119
138
|
'filter.search': '按名称、命令或路径筛选',
|
|
120
139
|
'filter.toolAll': '全部工具',
|
|
121
140
|
'filter.statusAll': '全部服务器',
|
|
122
141
|
'filter.statusMounted': '已挂载到 DSH',
|
|
123
142
|
'filter.statusUnmounted': '未挂载',
|
|
143
|
+
'filter.statusFailed': '已挂载但加载失败',
|
|
124
144
|
'label.project': '项目',
|
|
125
145
|
'label.global': '全局',
|
|
126
146
|
'label.profile': 'profile',
|
|
@@ -129,6 +149,10 @@ window.__ModuleLoader__.load({
|
|
|
129
149
|
'label.mounted': '已挂载',
|
|
130
150
|
'label.off': '未启用',
|
|
131
151
|
'label.shared': '出现在 {count} 个文件',
|
|
152
|
+
'label.loaded': '已加载',
|
|
153
|
+
'label.loadFailed': '加载失败',
|
|
154
|
+
'label.loadUnknown': '加载状态未知',
|
|
155
|
+
'btn.retry': '重试',
|
|
132
156
|
'switch.title': '挂载到 DeepSeek Harness(不会改动 {client} 本身)',
|
|
133
157
|
'detail.targets': '声明位置',
|
|
134
158
|
'detail.definition': '定义',
|
|
@@ -140,8 +164,20 @@ window.__ModuleLoader__.load({
|
|
|
140
164
|
'probe.ok': '正常 · {tools} 个工具 · {ms}ms',
|
|
141
165
|
'probe.failed': '失败',
|
|
142
166
|
'probe.tools': '{count} 个工具',
|
|
167
|
+
'load.loaded': '已加载 · {count} 个工具',
|
|
168
|
+
'load.failed': '加载失败',
|
|
169
|
+
'load.failedTitle': '已挂载到 DeepSeek Harness,但一个工具都没注册上 —— 说明连接没有成功。声明了零个工具的服务器看起来一样,建议点“测试”确认。',
|
|
170
|
+
'load.unknown': '加载状态未知',
|
|
171
|
+
'load.unknownTitle': '当前取不到工具注册表,无法从这里判断该服务器是否连上。',
|
|
172
|
+
'toast.retried': '已重连 “{name}” —— 现有 {count} 个工具可用。',
|
|
173
|
+
'toast.retryFailed': '“{name}” 仍未加载。点“测试”可以看清进程为什么起不来。',
|
|
174
|
+
'toast.retryReason': '无法重连 “{name}”:{reason}',
|
|
175
|
+
'toast.retryWarn': 'DeepSeek Harness 返回了 {count} 条警告,但连接已重建。',
|
|
176
|
+
'error.retry': 'DeepSeek Harness 无法重新发起这次连接。',
|
|
143
177
|
'banner.secrets': '有 {count} 个服务器把凭据以明文保存在可能被提交的配置文件里。',
|
|
144
178
|
'banner.parse': '有 {count} 个文件无法解析。',
|
|
179
|
+
'banner.loadFailed': '有 {count} 个服务器已挂载到 DeepSeek Harness,但连接失败 —— 没有任何工具注册成功。',
|
|
180
|
+
'btn.retryAll': '全部重试',
|
|
145
181
|
'confirm.sync': '把 “{name}” 复制到 {count} 个其他工具?',
|
|
146
182
|
'confirm.reveal': '以明文显示凭据?',
|
|
147
183
|
'error.title': '出错了',
|
|
@@ -245,17 +281,16 @@ window.__ModuleLoader__.load({
|
|
|
245
281
|
.mcpm-btn.primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: #fff; }
|
|
246
282
|
.mcpm-btn.primary:hover:not(:disabled) { border-color: var(--dsw-alias-brand-primary); opacity: .88; }
|
|
247
283
|
|
|
248
|
-
|
|
284
|
+
/* auto-fit wraps the stats instead of needing one breakpoint per column count,
|
|
285
|
+
which matters now that there are five of them rather than four. */
|
|
286
|
+
.mcpm-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); overflow: hidden;
|
|
249
287
|
border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px); background: var(--dsw-alias-bg-layer-1); }
|
|
250
288
|
.mcpm-stat { min-width: 0; overflow: hidden; padding: 4px 10px; border-right: 0.5px solid var(--dsw-alias-border-l1);
|
|
251
289
|
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
290
|
.mcpm-stat strong { margin-right: 4px; color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 650; }
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.mcpm-stat:nth-child(-n+2) { border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
|
|
258
|
-
}
|
|
291
|
+
/* A stat that reports a failure has to read as one at a glance. */
|
|
292
|
+
.mcpm-stat-bad { color: var(--dsw-alias-state-error-primary); }
|
|
293
|
+
.mcpm-stat-bad strong { color: var(--dsw-alias-state-error-primary); }
|
|
259
294
|
@container (max-width: 380px) {
|
|
260
295
|
.mcpm-summary { grid-template-columns: 1fr; }
|
|
261
296
|
.mcpm-stat { border-right: 0; border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
|
|
@@ -326,6 +361,9 @@ window.__ModuleLoader__.load({
|
|
|
326
361
|
.mcpm-banner { border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px);
|
|
327
362
|
padding: 9px 11px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
|
|
328
363
|
.mcpm-banner.warn { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); }
|
|
364
|
+
.mcpm-banner.err { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
|
|
365
|
+
.mcpm-banner-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
366
|
+
.mcpm-banner-row > span { flex: 1 1 220px; min-width: 0; }
|
|
329
367
|
.mcpm-error { border: 0.5px solid var(--dsw-alias-state-error-primary); border-radius: var(--dsw-radius-lg, 16px);
|
|
330
368
|
padding: 9px 11px; color: var(--dsw-alias-state-error-primary); font-size: 12px; line-height: 18px; }
|
|
331
369
|
.mcpm-error-lines { display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -541,6 +579,8 @@ window.__ModuleLoader__.load({
|
|
|
541
579
|
const [expanded, setExpanded] = useState({});
|
|
542
580
|
const [probes, setProbes] = useState({});
|
|
543
581
|
const [probing, setProbing] = useState({});
|
|
582
|
+
/* Keyed by server name so several failed rows can be retried independently. */
|
|
583
|
+
const [retrying, setRetrying] = useState({});
|
|
544
584
|
const [busy, setBusy] = useState(false);
|
|
545
585
|
const [toast, setToast] = useState(null);
|
|
546
586
|
const [snippet, setSnippet] = useState(null);
|
|
@@ -592,6 +632,9 @@ window.__ModuleLoader__.load({
|
|
|
592
632
|
* from: one name can be declared in five tools while being mounted in
|
|
593
633
|
* DeepSeek Harness exactly once. Indexing it here keeps every row's
|
|
594
634
|
* switch consistent with the DSH card.
|
|
635
|
+
*
|
|
636
|
+
* `load` is carried alongside because a mount is only half the answer:
|
|
637
|
+
* the row is mounted and still may never have connected.
|
|
595
638
|
*/
|
|
596
639
|
const mountIndex = useMemo(() => {
|
|
597
640
|
const index = new Map();
|
|
@@ -603,6 +646,7 @@ window.__ModuleLoader__.load({
|
|
|
603
646
|
enabled: dsh ? dsh.enabled !== false : false,
|
|
604
647
|
pending: dsh?.pending === true,
|
|
605
648
|
entryId: dsh?.entryId ?? null,
|
|
649
|
+
load: server.load ?? dsh?.load ?? null,
|
|
606
650
|
node: server,
|
|
607
651
|
});
|
|
608
652
|
}
|
|
@@ -637,6 +681,9 @@ window.__ModuleLoader__.load({
|
|
|
637
681
|
const isMounted = Boolean(mount?.mounted && mount.enabled);
|
|
638
682
|
if (statusFilter === 'mounted' && !isMounted) continue;
|
|
639
683
|
if (statusFilter === 'unmounted' && isMounted) continue;
|
|
684
|
+
/* The whole point of the filter: a mounted row whose connection never
|
|
685
|
+
came up. Servers the registry cannot judge are not claimed as bad. */
|
|
686
|
+
if (statusFilter === 'failed' && mount?.load?.status !== 'failed') continue;
|
|
640
687
|
if (needle) {
|
|
641
688
|
const haystack = [
|
|
642
689
|
item.name,
|
|
@@ -667,6 +714,38 @@ window.__ModuleLoader__.load({
|
|
|
667
714
|
[mountIndex],
|
|
668
715
|
);
|
|
669
716
|
|
|
717
|
+
/*
|
|
718
|
+
* Counted from the server list rather than from the filtered view: the
|
|
719
|
+
* headline numbers describe the machine, not the current search.
|
|
720
|
+
*/
|
|
721
|
+
const loadCounts = useMemo(() => {
|
|
722
|
+
const counts = { loaded: 0, failed: 0, off: 0, pending: 0, unknown: 0 };
|
|
723
|
+
for (const item of mountIndex.values()) {
|
|
724
|
+
const status = item.load?.status;
|
|
725
|
+
if (!status || status === 'absent') continue;
|
|
726
|
+
if (status in counts) counts[status] += 1;
|
|
727
|
+
}
|
|
728
|
+
return counts;
|
|
729
|
+
}, [mountIndex]);
|
|
730
|
+
|
|
731
|
+
/*
|
|
732
|
+
* The names that "Retry all" will visit, in list order. Only rows that are
|
|
733
|
+
* mounted, enabled and stuck in `failed` qualify — a row that is merely
|
|
734
|
+
* off must stay off, and a healthy row must not be bounced.
|
|
735
|
+
*/
|
|
736
|
+
const failedList = useMemo(
|
|
737
|
+
() => [...mountIndex.values()]
|
|
738
|
+
.filter((item) => item.mounted && item.enabled && item.load?.status === 'failed')
|
|
739
|
+
.map((item) => item.node.name),
|
|
740
|
+
[mountIndex],
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
/*
|
|
744
|
+
* Opt-in, not opt-out: an older host snapshot has no `loadSummary` at all,
|
|
745
|
+
* and that must read as "cannot tell" rather than as "registry is fine".
|
|
746
|
+
*/
|
|
747
|
+
const registryAvailable = state?.loadSummary?.registry === true;
|
|
748
|
+
|
|
670
749
|
const run = async (action, body, success, after) => {
|
|
671
750
|
setBusy(true);
|
|
672
751
|
setError(null);
|
|
@@ -736,6 +815,67 @@ window.__ModuleLoader__.load({
|
|
|
736
815
|
if (!result) flash(t('error.mount'));
|
|
737
816
|
};
|
|
738
817
|
|
|
818
|
+
/*
|
|
819
|
+
* Retry a server that is mounted but never connected.
|
|
820
|
+
*
|
|
821
|
+
* The failure is often environmental and temporary — the wrong Node was on
|
|
822
|
+
* PATH when the Host booted, a package was still downloading — so the fix
|
|
823
|
+
* is simply to run the connection again. That is a Host-side operation
|
|
824
|
+
* (dispose the dead fiber, then re-enable it), not a config edit.
|
|
825
|
+
*
|
|
826
|
+
* This does NOT go through run(): a failed retry answers with a specific
|
|
827
|
+
* reason ("could not dispose the stale connection: operation-error — …"),
|
|
828
|
+
* and a generic "it did not work" toast would throw that away.
|
|
829
|
+
*/
|
|
830
|
+
const retryLoad = async (name) => {
|
|
831
|
+
setRetrying((current) => ({ ...current, [name]: true }));
|
|
832
|
+
setError(null);
|
|
833
|
+
try {
|
|
834
|
+
const data = await api('retry', { name });
|
|
835
|
+
if (data?.state && mounted.current) setState(data.state);
|
|
836
|
+
if (data?.loaded) flash(t('toast.retried', { name, count: data.load?.toolCount ?? 0 }));
|
|
837
|
+
else flash(t('toast.retryFailed', { name }));
|
|
838
|
+
if (data?.warnings?.length) {
|
|
839
|
+
flash(t('toast.retryWarn', { count: data.warnings.length }));
|
|
840
|
+
}
|
|
841
|
+
/*
|
|
842
|
+
* A reconnect is not instant: the process still has to spawn and do its
|
|
843
|
+
* initialize handshake, so the snapshot in the response can be one beat
|
|
844
|
+
* too early. Re-read once after it has had a moment.
|
|
845
|
+
*/
|
|
846
|
+
setTimeout(() => { if (mounted.current) void load(reveal); }, 2500);
|
|
847
|
+
} catch (cause) {
|
|
848
|
+
/*
|
|
849
|
+
* Keep the reason visible: the usual cause is a spawn failure that
|
|
850
|
+
* only the Host knows about, so surfacing cause.message here is the
|
|
851
|
+
* whole point of the button.
|
|
852
|
+
*/
|
|
853
|
+
setError(cause);
|
|
854
|
+
flash(t('toast.retryReason', { name, reason: cause?.message ?? t('error.retry') }));
|
|
855
|
+
} finally {
|
|
856
|
+
setRetrying((current) => {
|
|
857
|
+
const next = { ...current };
|
|
858
|
+
delete next[name];
|
|
859
|
+
return next;
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
/*
|
|
865
|
+
* Retry every failed row in one go.
|
|
866
|
+
*
|
|
867
|
+
* Serial on purpose: each retry disposes and rebuilds one fiber, and doing
|
|
868
|
+
* them at once would spawn a burst of npm processes that fight for the same
|
|
869
|
+
* network and the same CPU, which makes a slow failure look like a hang.
|
|
870
|
+
* The list is recomputed from the LAST snapshot so a row that recovers
|
|
871
|
+
* halfway through is not retried twice.
|
|
872
|
+
*/
|
|
873
|
+
const retryAll = async () => {
|
|
874
|
+
const names = failedList;
|
|
875
|
+
if (names.length === 0) return;
|
|
876
|
+
for (const name of names) await retryLoad(name);
|
|
877
|
+
};
|
|
878
|
+
|
|
739
879
|
const doSync = async (server) => {
|
|
740
880
|
if (!window.confirm(t('confirm.sync', { name: server.name, count: syncPicks.length }))) return;
|
|
741
881
|
const data = await run('sync', { name: server.name, server: server.canonical, targets: syncPicks });
|
|
@@ -791,10 +931,23 @@ window.__ModuleLoader__.load({
|
|
|
791
931
|
h('p', { className: 'mcpm-desc', key: 'desc' }, t('desc')),
|
|
792
932
|
]);
|
|
793
933
|
|
|
934
|
+
/*
|
|
935
|
+
* "Mounted" alone is a lie the old page told: a row can be mounted and its
|
|
936
|
+
* process fail to start, which is exactly the case this stat exists to
|
|
937
|
+
* make visible. Loaded and failed are counted separately beside it.
|
|
938
|
+
*/
|
|
794
939
|
const summary = state ? h('div', { className: 'mcpm-summary', key: 'summary' }, [
|
|
795
940
|
h('div', { className: 'mcpm-stat', key: 'servers' }, [h('strong', { key: 'v' }, String(state.servers.length)), t('summary.servers')]),
|
|
796
941
|
h('div', { className: 'mcpm-stat', key: 'files' }, [h('strong', { key: 'v' }, String(sources.length)), t('summary.files')]),
|
|
797
942
|
h('div', { className: 'mcpm-stat', key: 'mounted' }, [h('strong', { key: 'v' }, String(mountedCount)), t('summary.mounted')]),
|
|
943
|
+
h('div', {
|
|
944
|
+
className: `mcpm-stat${loadCounts.failed > 0 ? ' mcpm-stat-bad' : ''}`,
|
|
945
|
+
key: 'loaded',
|
|
946
|
+
title: loadCounts.failed > 0 ? t('load.failedTitle') : undefined,
|
|
947
|
+
}, [
|
|
948
|
+
h('strong', { key: 'v' }, registryAvailable ? `${loadCounts.loaded}/${mountedCount}` : '—'),
|
|
949
|
+
t('summary.loaded'),
|
|
950
|
+
]),
|
|
798
951
|
h('div', { className: 'mcpm-stat', key: 'secrets' }, [h('strong', { key: 'v' }, String(state.literalSecrets.length)), t('summary.secrets')]),
|
|
799
952
|
]) : null;
|
|
800
953
|
|
|
@@ -831,6 +984,7 @@ window.__ModuleLoader__.load({
|
|
|
831
984
|
h('option', { key: 'all', value: 'all' }, t('filter.statusAll')),
|
|
832
985
|
h('option', { key: 'mounted', value: 'mounted' }, t('filter.statusMounted')),
|
|
833
986
|
h('option', { key: 'unmounted', value: 'unmounted' }, t('filter.statusUnmounted')),
|
|
987
|
+
h('option', { key: 'failed', value: 'failed' }, t('filter.statusFailed')),
|
|
834
988
|
]),
|
|
835
989
|
]) : null;
|
|
836
990
|
|
|
@@ -841,6 +995,22 @@ window.__ModuleLoader__.load({
|
|
|
841
995
|
state.warnings.length
|
|
842
996
|
? h('div', { className: 'mcpm-banner warn', key: 'parse' }, t('banner.parse', { count: state.warnings.length }))
|
|
843
997
|
: null,
|
|
998
|
+
/*
|
|
999
|
+
* Only when the registry is readable: without it every mounted row reads
|
|
1000
|
+
* as `unknown`, and claiming a failure there would be a guess.
|
|
1001
|
+
*/
|
|
1002
|
+
registryAvailable && failedList.length
|
|
1003
|
+
? h('div', { className: 'mcpm-banner err mcpm-banner-row', key: 'loadFailed' }, [
|
|
1004
|
+
h('span', { key: 't' }, t('banner.loadFailed', { count: failedList.length })),
|
|
1005
|
+
h('button', {
|
|
1006
|
+
key: 'r',
|
|
1007
|
+
type: 'button',
|
|
1008
|
+
className: 'mcpm-btn sm',
|
|
1009
|
+
disabled: busy,
|
|
1010
|
+
onClick: retryAll,
|
|
1011
|
+
}, t('btn.retryAll')),
|
|
1012
|
+
])
|
|
1013
|
+
: null,
|
|
844
1014
|
].filter(Boolean) : [];
|
|
845
1015
|
|
|
846
1016
|
const probeTag = (name) => {
|
|
@@ -857,6 +1027,40 @@ window.__ModuleLoader__.load({
|
|
|
857
1027
|
]);
|
|
858
1028
|
};
|
|
859
1029
|
|
|
1030
|
+
/*
|
|
1031
|
+
* The load badge, and the retry that belongs beside it.
|
|
1032
|
+
*
|
|
1033
|
+
* Only a mounted-and-enabled row can be loaded, so nothing is shown
|
|
1034
|
+
* otherwise — an unmounted server is not "failed", it is simply absent.
|
|
1035
|
+
* `unknown` is rendered as its own neutral state rather than as a failure,
|
|
1036
|
+
* because a scan without the tool registry must not accuse a healthy server.
|
|
1037
|
+
*/
|
|
1038
|
+
const loadTag = (mount, name) => {
|
|
1039
|
+
const load = mount?.load;
|
|
1040
|
+
if (!mount?.mounted || !mount.enabled || !load) return null;
|
|
1041
|
+
const status = load.status;
|
|
1042
|
+
if (status === 'off' || status === 'absent') return null;
|
|
1043
|
+
const parts = [];
|
|
1044
|
+
if (status === 'loaded') {
|
|
1045
|
+
parts.push(h('span', { className: 'mcpm-tag ok', key: 'ld' }, t('load.loaded', { count: load.toolCount ?? 0 })));
|
|
1046
|
+
} else if (status === 'failed') {
|
|
1047
|
+
parts.push(h('span', { className: 'mcpm-tag err', key: 'ld', title: t('load.failedTitle') }, t('load.failed')));
|
|
1048
|
+
parts.push(h('button', {
|
|
1049
|
+
key: 'rt',
|
|
1050
|
+
type: 'button',
|
|
1051
|
+
className: 'mcpm-btn sm',
|
|
1052
|
+
disabled: busy || (retrying[name] === true),
|
|
1053
|
+
title: t('btn.retry'),
|
|
1054
|
+
onClick: () => retryLoad(name),
|
|
1055
|
+
}, retrying[name] ? h('span', { className: 'mcpm-spin', key: 's' }) : t('btn.retry')));
|
|
1056
|
+
} else if (status === 'pending') {
|
|
1057
|
+
parts.push(h('span', { className: 'mcpm-tag', key: 'ld' }, t('label.pending')));
|
|
1058
|
+
} else {
|
|
1059
|
+
parts.push(h('span', { className: 'mcpm-tag', key: 'ld', title: t('load.unknownTitle') }, t('load.unknown')));
|
|
1060
|
+
}
|
|
1061
|
+
return parts;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
860
1064
|
const rowFor = (source, item) => {
|
|
861
1065
|
const mount = mountIndex.get(item.name);
|
|
862
1066
|
const node = mount?.node ?? null;
|
|
@@ -880,6 +1084,7 @@ window.__ModuleLoader__.load({
|
|
|
880
1084
|
shared > 1 ? h('span', { className: 'mcpm-tag', key: 'sh', title: node?.clients?.join(', ') ?? '' }, t('label.shared', { count: shared })) : null,
|
|
881
1085
|
mount?.pending ? h('span', { className: 'mcpm-tag warn', key: 'pd' }, t('label.pending')) : null,
|
|
882
1086
|
isDsh && !checked ? h('span', { className: 'mcpm-tag', key: 'off' }, t('label.off')) : null,
|
|
1087
|
+
loadTag(mount, item.name),
|
|
883
1088
|
probeTag(item.name),
|
|
884
1089
|
h(MountSwitch, {
|
|
885
1090
|
key: 'sw',
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { applyServerToTarget, removeServer, syncServer, snippetFor } from './ops
|
|
|
21
21
|
import { CLIENTS, CLIENT_BY_ID } from './adapters.js';
|
|
22
22
|
import { redactServer, isSecretKey, isSecretValue, maskValue, dshMountName } from './canonical.js';
|
|
23
23
|
import { toggleServer } from './toggle.js';
|
|
24
|
+
import { countLiveTools, liveToolNames, loadRecord } from './load.js';
|
|
24
25
|
import { mcpEntries } from './dsh.js';
|
|
25
26
|
|
|
26
27
|
export const name = 'mcp-manager';
|
|
@@ -68,6 +69,11 @@ const STATUS_BY_CODE = new Map([
|
|
|
68
69
|
['error.backup.missing', 404],
|
|
69
70
|
['error.route.missing', 404],
|
|
70
71
|
['error.action.unknown', 400],
|
|
72
|
+
['error.dsh.notMounted', 409],
|
|
73
|
+
['error.dsh.pending', 409],
|
|
74
|
+
['error.dsh.noEntry', 409],
|
|
75
|
+
['error.dsh.noHmr', 501],
|
|
76
|
+
['error.dsh.retry', 500],
|
|
71
77
|
]);
|
|
72
78
|
|
|
73
79
|
function statusFor(result) {
|
|
@@ -130,6 +136,7 @@ export function apply(ctx, config = {}) {
|
|
|
130
136
|
backupsToKeep: Number.isFinite(config.backupsToKeep) ? config.backupsToKeep : 20,
|
|
131
137
|
includeProjectScope: config.includeProjectScope !== false,
|
|
132
138
|
probeConcurrency: Number.isFinite(config.probeConcurrency) ? config.probeConcurrency : 4,
|
|
139
|
+
retryTimeoutMs: Number.isFinite(config.retryTimeoutMs) ? config.retryTimeoutMs : 25000,
|
|
133
140
|
};
|
|
134
141
|
|
|
135
142
|
const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh');
|
|
@@ -144,9 +151,16 @@ export function apply(ctx, config = {}) {
|
|
|
144
151
|
|
|
145
152
|
const configEditor = () => (typeof ctx.get === 'function' ? ctx.get('configEditor') : undefined);
|
|
146
153
|
|
|
154
|
+
/* The tool registry is the only truthful source of "did the connection
|
|
155
|
+
succeed": a connected server has registered `mcp__<mount>__<tool>` names,
|
|
156
|
+
while `@deepseek-ai/dsh-mcp-client` swallows a failed connect. Read lazily
|
|
157
|
+
so a profile without the service still scans (statuses become `unknown`). */
|
|
158
|
+
const toolRegistry = () => (typeof ctx.get === 'function' ? ctx.get('tools') : undefined);
|
|
159
|
+
|
|
147
160
|
const projectRoots = () => (options.includeProjectScope ? registeredWorkspaceRoots(ctx) : []);
|
|
148
161
|
|
|
149
|
-
const snapshot = (reveal = false) =>
|
|
162
|
+
const snapshot = (reveal = false) =>
|
|
163
|
+
scanAll({ store, configEditor: configEditor(), projectRoots: projectRoots(), reveal, profileDir, tools: toolRegistry() });
|
|
150
164
|
|
|
151
165
|
/* ---------------------------------------------------------- operations -- */
|
|
152
166
|
|
|
@@ -253,6 +267,25 @@ export function apply(ctx, config = {}) {
|
|
|
253
267
|
}
|
|
254
268
|
}
|
|
255
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Wait until a reconnected server has registered at least one tool.
|
|
272
|
+
*
|
|
273
|
+
* Polls the tool registry rather than the composition: a rebuilt fiber shows up
|
|
274
|
+
* in the composition immediately, but the connection is only proven by the
|
|
275
|
+
* `mcp__<mount>__*` names it registers. Returns the tool count it last saw, so
|
|
276
|
+
* a 0 after the budget expires is a measured failure, not a guess.
|
|
277
|
+
*/
|
|
278
|
+
async function waitForTools(mountName, budgetMs) {
|
|
279
|
+
const deadline = Date.now() + budgetMs;
|
|
280
|
+
for (;;) {
|
|
281
|
+
const names = liveToolNames(toolRegistry());
|
|
282
|
+
const count = names ? countLiveTools(names, mountName) : 0;
|
|
283
|
+
if (count > 0) return count;
|
|
284
|
+
if (Date.now() >= deadline) return count;
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
async function opProbe({ names, timeoutMs } = {}) {
|
|
257
290
|
const state = snapshot(true);
|
|
258
291
|
const wanted = Array.isArray(names) && names.length ? new Set(names) : null;
|
|
@@ -321,6 +354,107 @@ export function apply(ctx, config = {}) {
|
|
|
321
354
|
return store.restore(body.backup, body.path);
|
|
322
355
|
}
|
|
323
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Retry a mounted server that never connected.
|
|
359
|
+
*
|
|
360
|
+
* Nothing in the config is wrong when a connection fails — the process was
|
|
361
|
+
* simply not there yet (wrong Node version, a package that had not finished
|
|
362
|
+
* downloading, a laptop that had just woken up). The mcp-client keeps its own
|
|
363
|
+
* backoff timer for that, but once it gives up it registers nothing and waits
|
|
364
|
+
* for a reload that a settings page has no way to deliver.
|
|
365
|
+
*
|
|
366
|
+
* `hmr.reconcileProfile` looks like the right lever and is NOT. It recomposes
|
|
367
|
+
* the profile and re-imports the entry, but a failed mcp-client keeps its fiber
|
|
368
|
+
* `active` (it swallows the connect error under `failOnStartupError: false`),
|
|
369
|
+
* and reconcile only restarts entries that are INACTIVE. Measured against a
|
|
370
|
+
* dead row it returned success and rebuilt nothing — a retry button that lies.
|
|
371
|
+
*
|
|
372
|
+
* The lever that does rebuild the connection is the one the plugin manager
|
|
373
|
+
* itself uses: `setPluginEnabled(id, false)` disposes the mcp-client fiber and
|
|
374
|
+
* `setPluginEnabled(id, true)` recreates it, running `apply()` again — a fresh
|
|
375
|
+
* connection attempt. This does exactly that, then polls the tool registry
|
|
376
|
+
* (the only truthful proof of a successful connect) until tools appear or the
|
|
377
|
+
* budget runs out, so the answer is measured rather than assumed.
|
|
378
|
+
*
|
|
379
|
+
* Failure safety: the row is enabled again even when the enable call throws,
|
|
380
|
+
* because leaving a server switched off would be a worse outcome than a retry
|
|
381
|
+
* that did not help.
|
|
382
|
+
*/
|
|
383
|
+
async function opRetry(body) {
|
|
384
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
385
|
+
if (!name) return { ok: false, code: 'error.input', error: 'name is required' };
|
|
386
|
+
|
|
387
|
+
const state = snapshot(false);
|
|
388
|
+
const node = state.servers.find((item) => item.name === name);
|
|
389
|
+
if (!node) return { ok: false, code: 'error.server.missing', error: `unknown server ${name}` };
|
|
390
|
+
const target = node.targets.find((item) => item.clientId === 'dsh');
|
|
391
|
+
if (!target) return { ok: false, code: 'error.dsh.notMounted', error: `${name} is not mounted in DeepSeek Harness` };
|
|
392
|
+
if (target.pending === true) return { ok: false, code: 'error.dsh.pending', error: `${name} is still being mounted; wait for it to appear first` };
|
|
393
|
+
if (!target.entryId) return { ok: false, code: 'error.dsh.noEntry', error: `${name} has no Loader entry id; reload the plugin instead` };
|
|
394
|
+
|
|
395
|
+
const manager = typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined;
|
|
396
|
+
if (!manager || typeof manager.setPluginEnabled !== 'function') {
|
|
397
|
+
return { ok: false, code: 'error.dsh.noManager', error: 'the plugin manager is not available, so the connection cannot be rebuilt' };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const entryId = target.entryId;
|
|
401
|
+
const mountName = target.mountedAs ?? target.server?.mountName ?? dshMountName(name);
|
|
402
|
+
const before = node.load ?? null;
|
|
403
|
+
const warnings = [];
|
|
404
|
+
let disabled = false;
|
|
405
|
+
let enabled = null;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const off = await manager.setPluginEnabled(entryId, false);
|
|
409
|
+
for (const warning of off?.warnings ?? []) warnings.push(warning);
|
|
410
|
+
if (off?.application === 'failed') {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
code: 'error.dsh.retry',
|
|
414
|
+
error: `could not dispose the stale connection: ${off?.error?.code ?? 'unknown'}${off?.error?.diagnostic ? ` — ${off.error.diagnostic}` : ''}`,
|
|
415
|
+
before,
|
|
416
|
+
state: snapshot(false),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
disabled = true;
|
|
420
|
+
enabled = await manager.setPluginEnabled(entryId, true);
|
|
421
|
+
for (const warning of enabled?.warnings ?? []) warnings.push(warning);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
/* Put the row back the way the user left it before reporting the failure. */
|
|
424
|
+
if (disabled) {
|
|
425
|
+
try {
|
|
426
|
+
enabled = await manager.setPluginEnabled(entryId, true);
|
|
427
|
+
} catch { /* the retry error below is the one worth reporting */ }
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
code: 'error.dsh.retry',
|
|
432
|
+
error: String(error && error.message ? error.message : error),
|
|
433
|
+
before,
|
|
434
|
+
state: snapshot(false),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const toolCount = await waitForTools(mountName, options.retryTimeoutMs);
|
|
439
|
+
const after = snapshot(false).servers.find((item) => item.name === name);
|
|
440
|
+
const load = after?.load ?? loadRecord({ mountName, mounted: true, enabled: true, pending: false, counts: null, registry: false });
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
name,
|
|
445
|
+
entryId,
|
|
446
|
+
mountName,
|
|
447
|
+
before,
|
|
448
|
+
load,
|
|
449
|
+
loaded: load?.status === 'loaded',
|
|
450
|
+
toolCount,
|
|
451
|
+
warnings,
|
|
452
|
+
application: enabled?.application ?? null,
|
|
453
|
+
restartReason: enabled?.restartReason ?? null,
|
|
454
|
+
state: snapshot(false),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
324
458
|
const dispatch = async (action, body = {}) => {
|
|
325
459
|
switch (action) {
|
|
326
460
|
case 'scan': return opScan(body);
|
|
@@ -332,6 +466,7 @@ export function apply(ctx, config = {}) {
|
|
|
332
466
|
case 'reveal': return opReveal(body);
|
|
333
467
|
case 'toggle': return opToggle(body);
|
|
334
468
|
case 'restore': return opRestore(body);
|
|
469
|
+
case 'retry': return opRetry(body);
|
|
335
470
|
case 'history': return { ok: true, writes: store.recentWrites(100), backups: store.listBackups(100) };
|
|
336
471
|
default: return { ok: false, code: 'error.action.unknown', error: `unknown action ${action}` };
|
|
337
472
|
}
|
|
@@ -402,14 +537,15 @@ export function apply(ctx, config = {}) {
|
|
|
402
537
|
description: [
|
|
403
538
|
'Inspect and manage Model Context Protocol servers across the local AI development tools installed on this machine',
|
|
404
539
|
'(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
|
|
540
|
+
'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),',
|
|
541
|
+
'"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
542
|
'"toggle" to mount or unmount one server in DeepSeek Harness only (it never edits another tool\'s own config file),',
|
|
407
543
|
'"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
544
|
].join(' '),
|
|
409
545
|
parameters: {
|
|
410
546
|
type: 'object',
|
|
411
547
|
properties: {
|
|
412
|
-
action: { type: 'string', enum: ['scan', 'probe', 'apply', 'remove', 'sync', 'snippet', 'toggle', 'reveal', 'history'], description: 'Operation to perform.' },
|
|
548
|
+
action: { type: 'string', enum: ['scan', 'probe', 'apply', 'remove', 'sync', 'snippet', 'toggle', 'reveal', 'retry', 'history'], description: 'Operation to perform.' },
|
|
413
549
|
name: { type: 'string', description: 'Server name, as reported by scan.' },
|
|
414
550
|
enabled: { type: 'boolean', description: 'For toggle: true mounts the server in DeepSeek Harness, false unmounts it.' },
|
|
415
551
|
clientId: { type: 'string', description: 'Target client id (see scan output).' },
|
|
@@ -439,15 +575,44 @@ export function apply(ctx, config = {}) {
|
|
|
439
575
|
: (data.activating ? ' — DeepSeek Harness is activating it; its tools appear in a moment' : '');
|
|
440
576
|
return [{ type: 'text', text: `${data.name}: ${what}${when}` }];
|
|
441
577
|
}
|
|
578
|
+
if (data.name && data.load && typeof data.loaded === 'boolean') {
|
|
579
|
+
const before = data.before?.status ?? 'unknown';
|
|
580
|
+
const after = data.load?.status ?? 'unknown';
|
|
581
|
+
const lines = [`${data.name}: reconnect requested (was ${before}, now ${after}${data.load?.toolCount ? `, ${data.load.toolCount} tools` : ''}).`];
|
|
582
|
+
if (data.loaded) lines.push('Its tools are registered and callable.');
|
|
583
|
+
else if (after === 'failed') lines.push('Still no tools registered — run "probe" to see why the process will not start.');
|
|
584
|
+
else lines.push('The connection is still settling; scan again in a moment.');
|
|
585
|
+
for (const warning of data.warnings ?? []) lines.push(`warning: ${warning}`);
|
|
586
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
587
|
+
}
|
|
442
588
|
if (data.clients && data.servers) {
|
|
443
589
|
const lines = [`${data.servers.length} MCP server(s) across ${data.clients.filter((client) => client.installed).length} installed tool(s).`];
|
|
590
|
+
const summary = data.loadSummary;
|
|
591
|
+
if (summary && summary.mounted > 0) {
|
|
592
|
+
lines.push(summary.registry
|
|
593
|
+
? `Mounted in DeepSeek Harness: ${summary.mounted} — ${summary.loaded} loaded, ${summary.failed} failed to load${summary.off ? `, ${summary.off} off` : ''}${summary.pending ? `, ${summary.pending} activating` : ''}.`
|
|
594
|
+
: `Mounted in DeepSeek Harness: ${summary.mounted}. The tool registry is unavailable, so load state cannot be checked.`);
|
|
595
|
+
}
|
|
444
596
|
for (const server of data.servers.slice(0, 40)) {
|
|
445
597
|
/*
|
|
446
598
|
* 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.
|
|
599
|
+
* the line rather than in a second pass over the payload. A
|
|
600
|
+
* mounted-but-dead server is exactly what this feature exists to
|
|
601
|
+
* expose, so it says FAILED rather than just "mounted".
|
|
448
602
|
*/
|
|
449
603
|
const dsh = (server.targets ?? []).find((target) => target.clientId === 'dsh');
|
|
450
|
-
const
|
|
604
|
+
const load = server.load?.status;
|
|
605
|
+
const mount = !dsh
|
|
606
|
+
? ''
|
|
607
|
+
: dsh.enabled === false
|
|
608
|
+
? ' [dsh: off]'
|
|
609
|
+
: load === 'loaded'
|
|
610
|
+
? ` [dsh: loaded, ${server.load.toolCount} tools]`
|
|
611
|
+
: load === 'failed'
|
|
612
|
+
? ' [dsh: MOUNTED BUT FAILED TO LOAD]'
|
|
613
|
+
: load === 'pending'
|
|
614
|
+
? ' [dsh: activating]'
|
|
615
|
+
: ' [dsh: mounted, load unknown]';
|
|
451
616
|
lines.push(`- ${server.name} [${server.transport}]${mount} in: ${server.clients.join(', ') || 'none'}`);
|
|
452
617
|
}
|
|
453
618
|
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.
|
|
28
|
+
const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.5' };
|
|
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
|
|
|
@@ -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/lib/toggle.js
CHANGED
|
@@ -67,25 +67,6 @@ export async function toggleServer({ get, profileDir, store }, { name, enabled,
|
|
|
67
67
|
|
|
68
68
|
const manager = pluginManagerOf(get);
|
|
69
69
|
|
|
70
|
-
/*
|
|
71
|
-
* Unmounting a row WE created takes our own row out again.
|
|
72
|
-
*
|
|
73
|
-
* This runs first, before the Loader path and before the manager check, for
|
|
74
|
-
* three reasons: the Loader disposes the instance hot but does so by appending
|
|
75
|
-
* a `disabled: true` override beside the `- insert:` item, which leaves our
|
|
76
|
-
* row (and the override) in the user's composition forever; removing the
|
|
77
|
-
* insert item returns the file to exactly the bytes it had before we mounted
|
|
78
|
-
* anything; and removing a row needs no service, so the switch still works if
|
|
79
|
-
* the plugin manager is unavailable. A row the user authored is never touched
|
|
80
|
-
* here — `ownedByUs` requires our own ledger entry for it.
|
|
81
|
-
*/
|
|
82
|
-
if (!enabled && ownedByUs(store, rowName)) {
|
|
83
|
-
const removed = removeDshPatchServer({ serverName: rowName, store, profileDir });
|
|
84
|
-
if (removed.ok && removed.changed) {
|
|
85
|
-
return { ok: true, changed: true, enabled: false, via: 'patch', restartRequired: false, target: removed.path, unmounted: true };
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
70
|
if (!manager) {
|
|
90
71
|
/*
|
|
91
72
|
* No plugin manager: a row we can reach in the patch layer can still be
|
|
@@ -149,8 +130,30 @@ export async function toggleServer({ get, profileDir, store }, { name, enabled,
|
|
|
149
130
|
}
|
|
150
131
|
|
|
151
132
|
/*
|
|
152
|
-
* No live row answered.
|
|
153
|
-
*
|
|
133
|
+
* No live row answered. Unmounting a row WE created takes our own row back out
|
|
134
|
+
* rather than leaving a `disabled: true` override in the user's composition
|
|
135
|
+
* forever: removing the `- insert:` item returns the file to exactly the bytes
|
|
136
|
+
* it had before we mounted anything.
|
|
137
|
+
*
|
|
138
|
+
* This runs AFTER the Loader path, not before it. It used to run first, and
|
|
139
|
+
* that was a real bug: a mounted row we once created is also a live Loader
|
|
140
|
+
* entry, so the switch deleted the YAML row (a file edit that needs a reload)
|
|
141
|
+
* instead of disposing the running instance hot. Worse, because the removal
|
|
142
|
+
* appends the block back on re-enable, toggling a row MOVED it to the end of
|
|
143
|
+
* cordis.patch.yml — the user's file lost its authored ordering. The Loader is
|
|
144
|
+
* now always asked first; only a row the Loader cannot address is removed from
|
|
145
|
+
* the patch layer.
|
|
146
|
+
*/
|
|
147
|
+
if (!enabled && ownedByUs(store, rowName)) {
|
|
148
|
+
const removed = removeDshPatchServer({ serverName: rowName, store, profileDir });
|
|
149
|
+
if (removed.ok && removed.changed) {
|
|
150
|
+
return { ok: true, changed: true, enabled: false, via: 'patch', restartRequired: false, target: removed.path, unmounted: true };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
* Either the server is only an insert item we have not reconciled yet, or it
|
|
156
|
+
* is not mounted here at all.
|
|
154
157
|
*/
|
|
155
158
|
const pending = readDshPatchServers(profileDir).find((item) => item.name === rowName);
|
|
156
159
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-agent/dsh-mcp-manager",
|
|
3
|
-
"version": "0.1.
|
|
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,
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
+
|