@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 +68 -18
- package/README.md +34 -10
- package/lib/canonical.js +4 -4
- package/lib/client.js +145 -7
- package/lib/index.js +105 -5
- package/lib/load.js +102 -0
- package/lib/probe.js +1 -1
- package/lib/scan.js +61 -2
- 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.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.
|
|
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 —
|
|
241
|
-
|
|
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 —
|
|
249
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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}$/`。现实中很多服务器不满足 ——
|
|
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,如果结果丢了信息(中日韩字符、空格、超长),就追加原名的短哈希 ——
|
|
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
|
-
|
|
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/`
|
|
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
|
|
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 —
|
|
275
|
-
*
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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.
|
|
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.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
|
+
|