@oh-agent/dsh-mcp-manager 0.1.4 → 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 CHANGED
@@ -17,12 +17,12 @@ Install the bundle from the **Plugin Manager** page in Settings, or point
17
17
  `plugin_manager` at the npm package:
18
18
 
19
19
  ```
20
- plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.4"
20
+ plugin_manager action: install_bundle target: "@oh-agent/dsh-mcp-manager@0.1.5"
21
21
  ```
22
22
 
23
23
  An absolute directory works as `target` too, which is how you install a local
24
24
  checkout or the `dsh-mcp-manager.tgz` attached to the
25
- [v0.1.4 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
25
+ [v0.1.5 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases).
26
26
 
27
27
  Do not `pnpm add` the package into the profile by hand, and do not edit the
28
28
  profile's `package.json` or `cordis.patch.yml`: installing a package is not the
@@ -219,10 +219,23 @@ How the mount works, by case:
219
219
 
220
220
  | the server is | the switch does | takes effect |
221
221
  | --- | --- | --- |
222
- | 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
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 |
224
224
  | an `- insert:` item the user wrote | appends/removes the same `- id: …` / `disabled: true` override the plugin manager itself writes | on reconcile |
225
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".
226
239
  | not mounted anywhere, and switched off | nothing — already true, no dangling override | — |
227
240
 
228
241
  Our own row is removed before the Loader path is tried: the plugin manager can
@@ -258,23 +271,49 @@ That is the part that tells the truth.
258
271
  | --- | --- |
259
272
  | `loaded · N tools` | Connection succeeded, tools registered, the agent can call them now |
260
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 |
261
275
  | `activating` | The patch row was just written; the Loader has not recomposed yet |
262
276
  | `load state unknown` | The tool registry cannot be read (older host) — **no conclusion is drawn**, and it is never reported as a failure |
263
277
 
264
278
  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`,
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`,
276
313
  `failed` and `loaded`, plus the name matching that decides which tools belong to
277
- which server.
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.
278
317
 
279
318
  ### A mount name is not always the display name
280
319
 
package/README.md CHANGED
@@ -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.4"
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.4 release](https://github.com/wangbobo-coder/dsh-mcp-manager/releases) 里附的
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,12 +135,14 @@ 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` 还原到精确的原始字节。
@@ -157,14 +159,23 @@ client.js (设置页) index.js (宿主入口)
157
159
  | --- | --- |
158
160
  | `已加载 · N 个工具` | 连接成功,工具已注册,agent 现在就能调 |
159
161
  | `已挂载但加载失败` + 「重试」 | 行挂上了,但一个工具都没注册 —— 进程没起来或握手失败 |
162
+ | 页面顶部红色横幅 + 「全部重试」 | 有 N 个服务器挂上了却连不上,一次把失败的全部重试 |
160
163
  | `正在激活` | 刚写完补丁行,Loader 还没重排完 |
161
164
  | `加载状态未知` | 读不到工具注册表(老版本宿主),此时**不下结论**,绝不误判成失败 |
162
165
 
163
- 拿不到注册表时宁可显示「未知」也不显示「失败」:把一台健康的服务器标成坏的,比不标更糟。
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 会立刻出现在组合里,可只有它真注册上工具才算连上了。
164
175
 
165
- 「重试」走的是 `hmr.reconcileProfile([entryId])`(在 `hmr.runExclusive` 里串行执行),它会让 Loader 重新导入那些失败的行并判定激活结果 —— 连接失败之后的退避重试是 mcp-client 自己的事,可它一旦放弃就什么都不注册、只等一次重载,而设置页给不了这次重载,所以必须由我们来触发。重连不是瞬时的(起进程 + 握手),因此按钮点完 2.5 秒后页面会自己再读一次状态。
176
+ 为什么轮询注册表而不是直接看 fiber:fiber `active` 只说明「这个插件被加载了」,连不上也照样 active,这正是当初「显示已挂载、其实加载失败」的根源。重连不是瞬时的(起进程 + 握手),所以按钮点完 2.5 秒后页面会自己再读一次状态;等不到工具时提示里带的是宿主返回的真实原因,而不是一句「出错了」。
166
177
 
167
- `scripts/test-load.mjs` 钉住了这套判定,重点是 `unknown` / `failed` / `loaded` 三者的边界,以及按名字归属工具时不能张冠李戴。
178
+ `scripts/test-load.mjs` 钉住了判定边界(`unknown` / `failed` / `loaded` 三者不能互相冒充,按名字归属工具也不能张冠李戴),`scripts/test-retry.mjs` 钉住了重试本身:必须是「先关后开」两次调用、失败原因要原样透出、重建失败时不能把行留在关闭状态。
168
179
 
169
180
  ### 挂载名不总是显示名
170
181
 
package/lib/client.js CHANGED
@@ -88,9 +88,13 @@ window.__ModuleLoader__.load({
88
88
  'load.unknownTitle': 'The tool registry is not available, so whether this server connected cannot be checked from here.',
89
89
  'toast.retried': 'Reconnected “{name}” — {count} tools are live now.',
90
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.',
91
93
  'error.retry': 'DeepSeek Harness could not re-run this connection.',
92
94
  'banner.secrets': '{count} server(s) store credentials as literal text in a configuration file that may be committed.',
93
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',
94
98
  'confirm.sync': 'Copy “{name}” into {count} other tool(s)?',
95
99
  'confirm.reveal': 'Show credential values in clear text?',
96
100
  'error.title': 'Something went wrong',
@@ -167,9 +171,13 @@ window.__ModuleLoader__.load({
167
171
  'load.unknownTitle': '当前取不到工具注册表,无法从这里判断该服务器是否连上。',
168
172
  'toast.retried': '已重连 “{name}” —— 现有 {count} 个工具可用。',
169
173
  'toast.retryFailed': '“{name}” 仍未加载。点“测试”可以看清进程为什么起不来。',
174
+ 'toast.retryReason': '无法重连 “{name}”:{reason}',
175
+ 'toast.retryWarn': 'DeepSeek Harness 返回了 {count} 条警告,但连接已重建。',
170
176
  'error.retry': 'DeepSeek Harness 无法重新发起这次连接。',
171
177
  'banner.secrets': '有 {count} 个服务器把凭据以明文保存在可能被提交的配置文件里。',
172
178
  'banner.parse': '有 {count} 个文件无法解析。',
179
+ 'banner.loadFailed': '有 {count} 个服务器已挂载到 DeepSeek Harness,但连接失败 —— 没有任何工具注册成功。',
180
+ 'btn.retryAll': '全部重试',
173
181
  'confirm.sync': '把 “{name}” 复制到 {count} 个其他工具?',
174
182
  'confirm.reveal': '以明文显示凭据?',
175
183
  'error.title': '出错了',
@@ -353,6 +361,9 @@ window.__ModuleLoader__.load({
353
361
  .mcpm-banner { border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px);
354
362
  padding: 9px 11px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
355
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; }
356
367
  .mcpm-error { border: 0.5px solid var(--dsw-alias-state-error-primary); border-radius: var(--dsw-radius-lg, 16px);
357
368
  padding: 9px 11px; color: var(--dsw-alias-state-error-primary); font-size: 12px; line-height: 18px; }
358
369
  .mcpm-error-lines { display: flex; flex-direction: column; gap: 6px; }
@@ -717,6 +728,18 @@ window.__ModuleLoader__.load({
717
728
  return counts;
718
729
  }, [mountIndex]);
719
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
+
720
743
  /*
721
744
  * Opt-in, not opt-out: an older host snapshot has no `loadSummary` at all,
722
745
  * and that must read as "cannot tell" rather than as "registry is fine".
@@ -798,24 +821,37 @@ window.__ModuleLoader__.load({
798
821
  * The failure is often environmental and temporary — the wrong Node was on
799
822
  * PATH when the Host booted, a package was still downloading — so the fix
800
823
  * is simply to run the connection again. That is a Host-side operation
801
- * (a profile reconcile), not a config edit.
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.
802
829
  */
803
830
  const retryLoad = async (name) => {
804
831
  setRetrying((current) => ({ ...current, [name]: true }));
832
+ setError(null);
805
833
  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 }));
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 }));
812
837
  else flash(t('toast.retryFailed', { name }));
838
+ if (data?.warnings?.length) {
839
+ flash(t('toast.retryWarn', { count: data.warnings.length }));
840
+ }
813
841
  /*
814
842
  * A reconnect is not instant: the process still has to spawn and do its
815
843
  * initialize handshake, so the snapshot in the response can be one beat
816
844
  * too early. Re-read once after it has had a moment.
817
845
  */
818
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') }));
819
855
  } finally {
820
856
  setRetrying((current) => {
821
857
  const next = { ...current };
@@ -825,6 +861,21 @@ window.__ModuleLoader__.load({
825
861
  }
826
862
  };
827
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
+
828
879
  const doSync = async (server) => {
829
880
  if (!window.confirm(t('confirm.sync', { name: server.name, count: syncPicks.length }))) return;
830
881
  const data = await run('sync', { name: server.name, server: server.canonical, targets: syncPicks });
@@ -944,6 +995,22 @@ window.__ModuleLoader__.load({
944
995
  state.warnings.length
945
996
  ? h('div', { className: 'mcpm-banner warn', key: 'parse' }, t('banner.parse', { count: state.warnings.length }))
946
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,
947
1014
  ].filter(Boolean) : [];
948
1015
 
949
1016
  const probeTag = (name) => {
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';
@@ -135,6 +136,7 @@ export function apply(ctx, config = {}) {
135
136
  backupsToKeep: Number.isFinite(config.backupsToKeep) ? config.backupsToKeep : 20,
136
137
  includeProjectScope: config.includeProjectScope !== false,
137
138
  probeConcurrency: Number.isFinite(config.probeConcurrency) ? config.probeConcurrency : 4,
139
+ retryTimeoutMs: Number.isFinite(config.retryTimeoutMs) ? config.retryTimeoutMs : 25000,
138
140
  };
139
141
 
140
142
  const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh');
@@ -265,6 +267,25 @@ export function apply(ctx, config = {}) {
265
267
  }
266
268
  }
267
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
+
268
289
  async function opProbe({ names, timeoutMs } = {}) {
269
290
  const state = snapshot(true);
270
291
  const wanted = Array.isArray(names) && names.length ? new Set(names) : null;
@@ -342,11 +363,22 @@ export function apply(ctx, config = {}) {
342
363
  * backoff timer for that, but once it gives up it registers nothing and waits
343
364
  * for a reload that a settings page has no way to deliver.
344
365
  *
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.
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.
350
382
  */
351
383
  async function opRetry(body) {
352
384
  const name = typeof body.name === 'string' ? body.name.trim() : '';
@@ -360,34 +392,67 @@ export function apply(ctx, config = {}) {
360
392
  if (target.pending === true) return { ok: false, code: 'error.dsh.pending', error: `${name} is still being mounted; wait for it to appear first` };
361
393
  if (!target.entryId) return { ok: false, code: 'error.dsh.noEntry', error: `${name} has no Loader entry id; reload the plugin instead` };
362
394
 
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' };
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' };
366
398
  }
367
399
 
400
+ const entryId = target.entryId;
401
+ const mountName = target.mountedAs ?? target.server?.mountName ?? dshMountName(name);
368
402
  const before = node.load ?? null;
403
+ const warnings = [];
404
+ let disabled = false;
405
+ let enabled = null;
406
+
369
407
  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. */
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
+ }
376
429
  return {
377
- ok: true,
378
- name,
379
- entryId: target.entryId,
430
+ ok: false,
431
+ code: 'error.dsh.retry',
432
+ error: String(error && error.message ? error.message : error),
380
433
  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
434
  state: snapshot(false),
387
435
  };
388
- } catch (error) {
389
- return { ok: false, code: 'error.dsh.retry', error: String(error && error.message ? error.message : error) };
390
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
+ };
391
456
  }
392
457
 
393
458
  const dispatch = async (action, body = {}) => {
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.4' };
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/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. Either the server is only an insert item we have not
153
- * reconciled yet, or it is not mounted here at all.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-agent/dsh-mcp-manager",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",