@moon16u/dsh-plugin-mcp-console 0.1.2 → 0.2.0

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.md CHANGED
@@ -29,20 +29,28 @@ Then open DSH Web → bottom-left Settings → **"MCP servers"**.
29
29
  | Capability | Description |
30
30
  |---|---|
31
31
  | Server management | Add (stdio / streamable-http forms), edit, delete, enable/disable, reconnect; config changes take effect immediately, no DSH restart |
32
+ | Connection test | One-click "Test connection" per server: a throwaway official-client fiber (with `failOnStartupError` forced on) performs a real handshake and reports tool count + latency; for a running server it honestly reports the live status instead (the official client's serverName namespace cannot be bypassed) |
32
33
  | Per-tool switches | Expand a server card and click tool pills to toggle individual tools; disabled tools are denied at execution time by a guard |
33
34
  | Live status | running / connecting / starting / disabled / failed grading; SSE push updates, tool counts in real time |
34
35
  | JSON import | Paste Claude / Cursor style `mcpServers` JSON, preview, import; existing names skipped and reported |
35
36
  | Auto-ingest | MCP entries written into `cordis.patch.yml` by external agents/scripts are migrated into dynamic management on boot and on "Sync config & refresh status" (store persists before the YAML is trimmed — configuration is never lost) |
36
37
  | YAML export | `POST /api/dsh-mcp-console/export-yaml` restores dynamic config as standard `cordis.patch.yml` entries (uninstall/backup reversibility) |
38
+ | Model awareness | A `ctx.systemPrompt.section` announcement tells every agent that MCP management lives in the settings page, not in hand-edited config files (own inject scope; dormant on hosts without the service) |
39
+ | Settings integration | `enabled` / `announceToAgent` master switches registered against the official settings provider (`mcp-console` namespace, dual-generation API): editable from the settings GUI with live effect — `enabled` tears the whole surface down (routes + MCP connections + announcement; stores preserved), `announceToAgent` toggles only the announcement; hosts without the service fall back to entry defaults |
40
+ | Settings-GUI card | The "MCP console" card on Settings → Plugins (`settings.plugin.item`, key `mcp-console`): collapsible and shaped like the official cards (same `--dsw-alias-*` tokens and metrics), switches write immediately, and a changed field carries a "Customized" badge with its own reset |
41
+ | Degraded state | With `enabled` off, the "MCP servers" page stops reporting an API error and shows the switched-off notice plus the way back (an authenticated 404 on our own prefix is the tell), and keeps the SSE stream closed instead of retrying |
37
42
 
38
43
  ## Config storage
39
44
 
40
45
  - Dynamic config: `~/.dsh/dsh-mcp.json` (versioned JSON, atomic writes: temp file + rename; deletions are explicit user intent, no backup files are kept)
46
+ - Plugin switches: the official settings user document's `mcp-console` section (edited via the settings GUI, persisted by the provider)
41
47
  - Profile layer: `~/.dsh/profiles/web/cordis.patch.yml` (the ingest engine touches only mcp-client entries; other entries and comments are preserved verbatim)
42
48
 
43
49
  ## HTTP API (loopback-only, prefix `/api/dsh-mcp-console/`)
44
50
 
45
- `GET /health` · `GET/POST /servers` · `PATCH/DELETE /servers/:name` · `POST /servers/:name/enable|disable|reconnect` · `POST /refresh` (ingest YAML + retry failed) · `POST /import` · `POST /export-yaml` · `GET /events` (SSE) · `GET/PUT /config` (UI config)
51
+ `GET /health` · `GET/POST /servers` · `PATCH/DELETE /servers/:name` · `POST /servers/:name/enable|disable|reconnect|probe` · `POST /refresh` (ingest YAML + retry failed) · `POST /import` · `POST /export-yaml` · `GET /events` (SSE) · `GET/PUT /config` (UI config)
52
+
53
+ **Four-layer loopback fence** (borrowed from dsh-skills-mcp-manager): loopback socket peer, loopback Host header (DNS-rebinding fence), no `sec-fetch-site: cross-site` marker, and — when Origin is present — an Origin matching the Host. `GET /config` (UI placement only) stays open to LAN browsers.
46
54
 
47
55
  ## Known limitations (honest wording)
48
56
 
@@ -50,11 +58,12 @@ Then open DSH Web → bottom-left Settings → **"MCP servers"**.
50
58
  2. **Per-tool disable exposure**: `tools.restrict()` requires an agent scope in dsh-tools (context-global restriction is explicitly forbidden), so a disabled tool still appears in the model's tool list — but its calls are denied by the guard with a clear reason.
51
59
  3. **First-migration boot race**: with small probability the loader and the ingest engine assemble the same serverName during boot; the loader-side fiber logs one duplicate error (harmless — the YAML is already trimmed, so it does not recur on the next boot).
52
60
  4. **rc-version coupling**: depends on cordis/webserver/tools contracts of the official packages; all official-package coupling is isolated in `clientAdapter.js` (the official client is resolved dynamically from the profile's module graph, sharing the loader's module instance) and `routes.js`.
61
+ 5. **"Test connection" on a running server**: the official client reserves the serverName namespace per module instance, so a live server cannot be re-probed alongside its own fiber; the button then returns the live status (`live: true`) instead of a fresh handshake. Fresh side-fiber probes carry a hard 15s timeout.
53
62
 
54
63
  ## Development
55
64
 
56
65
  ```bash
57
- pnpm --filter @moon16u/dsh-plugin-mcp-console test # 43 tests
66
+ pnpm --filter @moon16u/dsh-plugin-mcp-console test # 73 tests
58
67
  node scripts/sync-root-client.mjs # after editing lib/client.js, sync into the pouch root bundle
59
68
  ```
60
69
 
package/README.zh-CN.md CHANGED
@@ -22,20 +22,28 @@ dsh plugin --profile web add @moon16u/dsh-plugin-mcp-console
22
22
  | 能力 | 说明 |
23
23
  |---|---|
24
24
  | 服务器管理 | 新增(stdio / streamable-http 双模式表单)、编辑、删除、启停、重连;改配置即时生效,无需重启 dsh(删除即用户意志,不留备份文件) |
25
+ | 连接测试 | 每台服务器一键「测试连接」:起一个一次性官方 client fiber(强制 `failOnStartupError`)真实握手,报告工具数与延迟;对运行中的服务器则如实报告实时状态(官方 client 的 serverName 命名空间不允许旁路重连) |
25
26
  | 工具级开关 | 展开服务器卡片,点击工具胶囊单独启停;禁用的工具调用会被执行期 guard 拒绝 |
26
27
  | 实时状态 | 运行中/连接中/启动中/已停用/失败 分级;SSE 推送刷新,工具数实时联动 |
27
28
  | JSON 导入 | 粘贴 Claude / Cursor 风格 `mcpServers` JSON,预览后导入,同名跳过并报告 |
28
29
  | Auto-Ingest | 外部 agent / 脚本按官方推荐写入 `cordis.patch.yml` 的 MCP 条目,在启动与「同步配置并刷新状态」时自动移入动态管理(store 持久化成功才修剪 YAML,绝不丢配置) |
29
30
  | YAML 回写 | `POST /api/dsh-mcp-console/export-yaml` 把动态配置一键还原为标准 `cordis.patch.yml` 条目(卸载/备份可逆) |
31
+ | 模型感知 | 通过 `ctx.systemPrompt.section` 向每个 agent 的系统提示注入一段公告:MCP 管理请到设置页操作,勿手改配置文件(独立 inject 作用域,无该服务的宿主上自动休眠) |
32
+ | 设置集成 | `enabled` / `announceToAgent` 总开关走官方 settings provider(`mcp-console` 命名空间,双世代 API 兼容),设置 GUI 可改、即改即生效:`enabled` 关闭整体下线(路由 + MCP 连接 + 公告,store 保留),`announceToAgent` 只控制公告;无 settings 服务的宿主回落条目默认值 |
33
+ | 设置 GUI 卡片 | 设置 → 插件 页的「MCP 控制台」卡片(`settings.plugin.item`,键 `mcp-console`):可折叠、与官方卡片同形(同一批 `--dsw-alias-*` 令牌与尺寸),开关即改即写,被改过的字段带「已自定义」徽章与逐字段「恢复默认」 |
34
+ | 停用后的降级 | `enabled` 关闭后「MCP 服务器」页不再报接口错误,改为显示停用提示与恢复入口(自有前缀路由的 404 即判定为已停用),并停开 SSE 以免反复重试 |
30
35
 
31
36
  ## 配置存储
32
37
 
33
38
  - 动态配置:`~/.dsh/dsh-mcp.json`(版本化 JSON,原子写:临时文件 + rename,不产生备份文件)
39
+ - 插件开关:DSH 官方 settings 用户文档的 `mcp-console` 节(设置 GUI 编辑、官方持久化)
34
40
  - profile 层:`~/.dsh/profiles/web/cordis.patch.yml`(ingest 引擎只动其中的 mcp-client 条目,其余条目与注释原样保留)
35
41
 
36
42
  ## HTTP API(loopback-only,前缀 `/api/dsh-mcp-console/`)
37
43
 
38
- `GET /health` · `GET/POST /servers` · `PATCH/DELETE /servers/:name` · `POST /servers/:name/enable|disable|reconnect` · `POST /refresh`(吸纳 YAML + 重试失败)· `POST /import` · `POST /export-yaml` · `GET /events`(SSE)· `GET/PUT /config`(UI 配置)
44
+ `GET /health` · `GET/POST /servers` · `PATCH/DELETE /servers/:name` · `POST /servers/:name/enable|disable|reconnect|probe` · `POST /refresh`(吸纳 YAML + 重试失败)· `POST /import` · `POST /export-yaml` · `GET /events`(SSE)· `GET/PUT /config`(UI 配置)
45
+
46
+ **回环围栏四层校验**(借鉴 dsh-skills-mcp-manager):socket 对端地址、Host 头必须是回环源(防 DNS rebinding)、拒绝 `sec-fetch-site: cross-site`、Origin(若携带)必须与 Host 同源。`GET /config`(仅 UI 位置)对局域网浏览器开放。
39
47
 
40
48
  ## 已知限制(诚实口径)
41
49
 
@@ -43,11 +51,12 @@ dsh plugin --profile web add @moon16u/dsh-plugin-mcp-console
43
51
  2. **工具级禁用的暴露面**:`tools.restrict()` 在 dsh-tools 中要求 agent-scope(全局限制被明确禁止),故被禁用的工具仍出现在模型工具列表中,但调用被 guard 拒绝并在结果中说明。
44
52
  3. **首次迁移的启动竞态**:极小概率下 boot 期 loader 与 ingest 引擎同时装配同一 serverName,loader 侧 fiber 会报一次 duplicate 错误(无害,下次启动 YAML 已修剪,不再复现)。
45
53
  4. **rc 版本耦合**:依赖官方包的 cordis/webserver/tools 契约,收敛在 `clientAdapter.js`(动态解析 profile 里的官方 client,与 loader 共享同一模块实例)与 `routes.js`。
54
+ 5. **运行中服务器的「测试连接」**:官方 client 的 serverName 命名空间按模块实例保留,运行中的服务器无法旁路重连测试;此时按钮返回实时状态(`live: true`)而非新握手结果。新握手探测硬超时 15s。
46
55
 
47
56
  ## 开发
48
57
 
49
58
  ```bash
50
- pnpm --filter @moon16u/dsh-plugin-mcp-console test # 42 个测试
59
+ pnpm --filter @moon16u/dsh-plugin-mcp-console test # 73 个测试
51
60
  node scripts/sync-root-client.mjs # 改 lib/client.js 后同步进 dsh-pouch 根 bundle
52
61
  ```
53
62
 
package/lib/client.js CHANGED
@@ -84,6 +84,27 @@ window.__ModuleLoader__.load({
84
84
  disabled: "已停用",
85
85
  failed: "失败",
86
86
  toolDeniedNote: "禁用的工具仍保留在模型工具列表中,但调用会被拒绝。",
87
+ test: "测试连接",
88
+ probeBusy: "测试中…",
89
+ probeOk: "连接成功 · {n} 个工具 · {ms}ms",
90
+ probeLiveOk: "已在运行 · {n} 个工具(实时状态)",
91
+ probeLiveFail: "未正常连接(实时状态):{error}",
92
+ probeFail: "连接失败:{error}",
93
+ pluginCardTitle: "MCP 控制台",
94
+ pluginCardDesc: "MCP 服务器的连接、工具开关与导入。",
95
+ pluginCardEnabled: "启用 MCP 控制台",
96
+ pluginCardEnabledHelp: "关闭后:路由、全部 MCP 连接与模型公告下线;配置保留在 ~/.dsh/dsh-mcp.json",
97
+ pluginCardAnnounce: "向模型公告 MCP 管理能力",
98
+ pluginCardAnnounceHelp: "在系统提示中告知 agent:MCP 管理请到设置页操作,不要手改配置文件",
99
+ pluginCardOverridden: "已自定义",
100
+ pluginCardReset: "恢复默认",
101
+ sectionOffline: "MCP 控制台当前已停用。",
102
+ sectionOfflineHint: "到 设置 → 插件 → MCP 控制台 打开「启用 MCP 控制台」即可恢复;服务器配置一直保留在 ~/.dsh/dsh-mcp.json。",
103
+ pluginCardExpand: "展开设置",
104
+ pluginCardCollapse: "收起设置",
105
+ pluginCardUnavailable: "此客户端未暴露插件设置(只读或本地模式)。",
106
+ pluginCardLoading: "加载中…",
107
+ pluginCardWriteError: "写入失败",
87
108
  };
88
109
 
89
110
  var mcpEn = {
@@ -152,6 +173,27 @@ window.__ModuleLoader__.load({
152
173
  disabled: "Disabled",
153
174
  failed: "Failed",
154
175
  toolDeniedNote: "Disabled tools stay in the model tool list, but their calls are denied.",
176
+ test: "Test connection",
177
+ probeBusy: "Testing…",
178
+ probeOk: "Connected · {n} tools · {ms}ms",
179
+ probeLiveOk: "Running · {n} tools (live status)",
180
+ probeLiveFail: "Not healthy (live status): {error}",
181
+ probeFail: "Connection failed: {error}",
182
+ pluginCardTitle: "MCP console",
183
+ pluginCardDesc: "Connections, per-tool switches and import for MCP servers.",
184
+ pluginCardEnabled: "Enable the MCP console",
185
+ pluginCardEnabledHelp: "Off: routes, every MCP connection and the model announcement go offline; config stays in ~/.dsh/dsh-mcp.json",
186
+ pluginCardAnnounce: "Announce MCP management to the model",
187
+ pluginCardAnnounceHelp: "Tells agents in the system prompt to use the settings page for MCP management, not hand-edited files",
188
+ pluginCardOverridden: "Customized",
189
+ pluginCardReset: "Reset to defaults",
190
+ sectionOffline: "The MCP console is switched off.",
191
+ sectionOfflineHint: "Turn \"Enable the MCP console\" back on under Settings → Plugins → MCP console; server configuration stays in ~/.dsh/dsh-mcp.json.",
192
+ pluginCardExpand: "Expand settings",
193
+ pluginCardCollapse: "Collapse settings",
194
+ pluginCardUnavailable: "Plugin settings are not exposed to this client (read-only or local mode).",
195
+ pluginCardLoading: "Loading…",
196
+ pluginCardWriteError: "Write failed",
155
197
  };
156
198
 
157
199
  var mcpCss = [
@@ -194,6 +236,37 @@ window.__ModuleLoader__.load({
194
236
  ".mcp-mgr-name-btn{background:0 0;border:0;padding:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;cursor:pointer;text-align:left;min-width:0}",
195
237
  ".mcp-mgr-name-btn:hover{text-decoration:underline;text-underline-offset:3px}",
196
238
  ".mcp-mgr-err{color:var(--dsw-alias-state-error-primary,#ef4444);margin:0;font-size:12px;word-break:break-word}",
239
+ ".mcp-probe{margin:0;font-size:12px;line-height:18px;word-break:break-word}",
240
+ ".mcp-probe[data-tone=run]{color:var(--dsw-alias-label-secondary,#6b7280)}",
241
+ ".mcp-probe[data-tone=ok]{color:var(--dsw-alias-state-success-primary,#10b981)}",
242
+ ".mcp-probe[data-tone=bad]{color:var(--dsw-alias-state-error-primary,#ef4444)}",
243
+ ".mcp-plugin-card{list-style:none;margin:0;border:.5px solid var(--dsw-alias-border-l4,#e5e7eb);border-radius:16px;background:var(--dsw-alias-bg-layer-3,#ffffff);transition:border-color .16s,background .16s}",
244
+ ".mcp-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed,#d1d5db)}",
245
+ ".mcp-plugin-card[data-open=true]{background:var(--dsw-alias-bg-layer-2,#fafafa);border-color:var(--dsw-alias-label-dimmed,#d1d5db)}",
246
+ ".mcp-plugin-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}",
247
+ ".mcp-plugin-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:-2px}",
248
+ ".mcp-plugin-headtext{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}",
249
+ ".mcp-plugin-name{color:var(--dsw-alias-label-primary,#111827);font-size:15px;font-weight:600;line-height:1.4}",
250
+ ".mcp-plugin-desc{color:var(--dsw-alias-label-tertiary,#9ca3af);font-size:13px;line-height:1.5}",
251
+ ".mcp-plugin-chevron{color:var(--dsw-alias-label-tertiary,#9ca3af);flex:none;transition:transform .16s}",
252
+ ".mcp-plugin-card[data-open=true] .mcp-plugin-chevron{transform:rotate(180deg)}",
253
+ ".mcp-plugin-body{border-top:.5px solid var(--dsw-alias-border-l2,#e5e7eb);margin:0 16px;padding-bottom:8px}",
254
+ ".mcp-plugin-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}",
255
+ ".mcp-plugin-field + .mcp-plugin-field{border-top:.5px solid var(--dsw-alias-border-l2,#e5e7eb)}",
256
+ ".mcp-plugin-field-head{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary,#111827);font-size:13px;line-height:1.5}",
257
+ ".mcp-plugin-field-label{flex:1;min-width:0;font-weight:500}",
258
+ ".mcp-plugin-badge{flex:none;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,#f3f4f6);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}",
259
+ ".mcp-plugin-reset{flex:none;font:inherit;color:var(--dsw-alias-label-secondary,#6b7280);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}",
260
+ ".mcp-plugin-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary,#111827)}",
261
+ ".mcp-plugin-reset:disabled{cursor:default;opacity:.5}",
262
+ ".mcp-plugin-switch{box-sizing:border-box;flex:none;position:relative;width:36px;height:20px;padding:2px;border:0;border-radius:10px;cursor:pointer;background:var(--dsw-alias-border-l3,#d1d5db);transition:background .12s}",
263
+ ".mcp-plugin-switch[aria-checked=true]{background:var(--dsw-alias-brand-primary,#4d6bfe)}",
264
+ ".mcp-plugin-switch:disabled{cursor:default;opacity:.5}",
265
+ ".mcp-plugin-switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:2px}",
266
+ ".mcp-plugin-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground,#ffffff);transition:transform .12s}",
267
+ ".mcp-plugin-switch[aria-checked=true] .mcp-plugin-thumb{transform:translateX(16px)}",
268
+ ".mcp-plugin-hint{color:var(--dsw-alias-label-tertiary,#9ca3af);margin:0;font-size:12px;line-height:1.5}",
269
+ ".mcp-plugin-err{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:1.5;margin:0;padding-bottom:4px;word-break:break-word}",
197
270
  ".mcp-mgr-tools{display:flex;flex-wrap:wrap;gap:8px;padding-top:4px}",
198
271
  ".mcp-pill-wrap{position:relative;display:inline-flex}",
199
272
  ".mcp-pill{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-base,#ffffff);color:var(--dsw-alias-label-primary,#374151);border-radius:6px;padding:3px 10px;font-size:12px;line-height:18px;font-family:var(--ds-font-family-code,ui-monospace,monospace);cursor:pointer;transition:all .15s ease;user-select:none}",
@@ -273,14 +346,28 @@ window.__ModuleLoader__.load({
273
346
  } catch {}
274
347
  if (!response.ok) {
275
348
  var message = body && body.error && body.error.message ? body.error.message : response.status + " " + response.statusText;
276
- throw new Error(message);
349
+ var failure = new Error(message);
350
+ // the console's own prefix route only disappears when the master
351
+ // switch took the composition down — the section reads this to say
352
+ // "disabled" instead of showing a raw API error
353
+ failure.status = response.status;
354
+ throw failure;
277
355
  }
278
356
  return body;
279
357
  });
280
358
  }
281
359
 
282
- function mcpFill(template, values) {
283
- return String(template).replace(/\{(\w+)\}/g, function (_, key) {
360
+ /**
361
+ * Whether an mcpApi failure means "the console is switched off" rather than
362
+ * a genuine API error: only our own prefix route answers under
363
+ * /api/dsh-mcp-console, so an authenticated 404 there is the composition
364
+ * being torn down by the `enabled` master switch.
365
+ */
366
+ function mcpConsoleOffline(failure) {
367
+ return Boolean(failure) && failure.status === 404;
368
+ }
369
+
370
+ function mcpFill(template, values) { return String(template).replace(/\{(\w+)\}/g, function (_, key) {
284
371
  return Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : "{" + key + "}";
285
372
  });
286
373
  }
@@ -341,11 +428,32 @@ window.__ModuleLoader__.load({
341
428
  }
342
429
 
343
430
  /**
344
- * One server item: header row (name + status + badge | delete + master
345
- * switch), summary row (chevron + count, expand trigger), tool pills.
346
- * Read-only entries (profile-declared, review §2-4) hide delete, disable
347
- * switch and pills, and skip tooltips. Editing opens by clicking the
348
- * server name (the explicit edit button is gone per review §2-2).
431
+ * One probe outcome line ("Test connection"): busy / connected-with-
432
+ * latency / live-status report / failure. null while never probed.
433
+ */
434
+ function mcpProbeLine(probe, t) {
435
+ if (probe.busy === true) {
436
+ return McpH("p", { className: "mcp-probe", "data-tone": "run" }, t("probeBusy"));
437
+ }
438
+ if (probe.ok) {
439
+ return McpH("p", { className: "mcp-probe", "data-tone": "ok" },
440
+ probe.live === true
441
+ ? mcpFill(t("probeLiveOk"), { n: probe.toolCount })
442
+ : mcpFill(t("probeOk"), { n: probe.toolCount, ms: probe.latencyMs }));
443
+ }
444
+ return McpH("p", { className: "mcp-probe", "data-tone": "bad" },
445
+ probe.live === true
446
+ ? mcpFill(t("probeLiveFail"), { error: probe.error || "" })
447
+ : mcpFill(t("probeFail"), { error: probe.error || "" }));
448
+ }
449
+
450
+ /**
451
+ * One server item: header row (name + status + badge | test + delete +
452
+ * master switch), summary row (chevron + count, expand trigger), probe
453
+ * result line, tool pills. Read-only entries (profile-declared, review
454
+ * §2-4) hide test, delete, disable switch and pills, and skip tooltips.
455
+ * Editing opens by clicking the server name (the explicit edit button is
456
+ * gone per review §2-2).
349
457
  */
350
458
  function McpServerItem(props) {
351
459
  var t = props.t;
@@ -373,6 +481,12 @@ window.__ModuleLoader__.load({
373
481
  McpH("span", { className: "mcp-dot", "data-status": server.status, title: statusLabel, key: "dot" }),
374
482
  readOnly ? McpH("span", { className: "mcp-readonly-badge", title: t("profileReadOnly") }, t("profileReadOnly")) : null,
375
483
  McpH("span", { className: "mcp-mgr-ops" },
484
+ readOnly ? null : McpH("button", {
485
+ type: "button", className: "mcp-mgr-iconbtn",
486
+ disabled: busy || (props.probe && props.probe.busy === true),
487
+ title: t("test"), "aria-label": t("test"),
488
+ onClick: function () { props.onProbe(server); },
489
+ }, McpH(McpIcons.IconLinkOutline16, { size: 14 })),
376
490
  readOnly ? null : McpH("button", {
377
491
  type: "button", className: "mcp-mgr-del", disabled: busy,
378
492
  title: t("remove"), "aria-label": t("remove"),
@@ -390,6 +504,7 @@ window.__ModuleLoader__.load({
390
504
  },
391
505
  McpH("span", { className: "mcp-mgr-chevron" }),
392
506
  McpH("span", null, summary))),
507
+ props.probe ? mcpProbeLine(props.probe, t) : null,
393
508
  server.error ? McpH("p", { className: "mcp-mgr-err" }, t("errorPrefix") + ": " + server.error) : null,
394
509
  open
395
510
  ? (server.toolCount === 0
@@ -694,11 +809,26 @@ window.__ModuleLoader__.load({
694
809
  var expanded = expandedState[0], setExpanded = expandedState[1];
695
810
  var refreshingState = useState(false);
696
811
  var refreshing = refreshingState[0], setRefreshing = refreshingState[1];
812
+ var probeState = useState({});
813
+ var probes = probeState[0], setProbes = probeState[1]; // name -> {busy}|result
814
+ var offlineState = useState(false);
815
+ var offline = offlineState[0], setOffline = offlineState[1];
816
+
817
+ /** A 404 on our own prefix means the master switch took the routes down. */
818
+ function receiveFailure(failure) {
819
+ if (mcpConsoleOffline(failure)) {
820
+ setOffline(true);
821
+ setError(null);
822
+ return;
823
+ }
824
+ setError(failure instanceof Error ? failure.message : String(failure));
825
+ }
697
826
 
698
827
  function load() {
699
- return mcpApi("/servers").then(setSnapshot, function (failure) {
700
- setError(failure instanceof Error ? failure.message : String(failure));
701
- });
828
+ return mcpApi("/servers").then(function (body) {
829
+ setOffline(false);
830
+ setSnapshot(body);
831
+ }, receiveFailure);
702
832
  }
703
833
 
704
834
  useEffect(function () {
@@ -706,13 +836,17 @@ window.__ModuleLoader__.load({
706
836
  mcpApi("/servers").then(function (body) {
707
837
  if (!stale) setSnapshot(body);
708
838
  }, function (failure) {
709
- if (!stale) setError(failure instanceof Error ? failure.message : String(failure));
839
+ if (!stale) receiveFailure(failure);
710
840
  });
711
841
  return function () { stale = true; };
712
842
  }, []);
713
843
 
714
844
  // Live updates while the settings page is open (plan §5.4 /events).
845
+ // Opened only once data arrived: with the console switched off the route
846
+ // is gone, and EventSource would retry the 404 every few seconds.
847
+ var streamReady = snapshot !== null;
715
848
  useEffect(function () {
849
+ if (!streamReady) return undefined;
716
850
  var source = new EventSource("/api/dsh-mcp-console/events");
717
851
  source.addEventListener("status_changed", function (event) {
718
852
  try {
@@ -720,7 +854,7 @@ window.__ModuleLoader__.load({
720
854
  } catch {}
721
855
  });
722
856
  return function () { source.close(); };
723
- }, []);
857
+ }, [streamReady]);
724
858
 
725
859
  useEffect(function () {
726
860
  function onKey(event) {
@@ -779,6 +913,35 @@ window.__ModuleLoader__.load({
779
913
  setConfirm(server.name);
780
914
  }
781
915
 
916
+ /**
917
+ * "Test connection" (borrowed from dsh-skills-mcp-manager): the host
918
+ * loads one throwaway official-client fiber, so the outcome (tools,
919
+ * latency, error) is a real handshake result. Results live per card,
920
+ * separate from withBusy so a probe never blocks other controls.
921
+ */
922
+ function probeServer(server) {
923
+ setProbes(function (previous) {
924
+ var next = { ...previous };
925
+ next[server.name] = { busy: true };
926
+ return next;
927
+ });
928
+ mcpApi("/servers/" + encodeURIComponent(server.name) + "/probe", { method: "POST" }).then(
929
+ function (result) {
930
+ setProbes(function (previous) {
931
+ var next = { ...previous };
932
+ next[server.name] = result;
933
+ return next;
934
+ });
935
+ },
936
+ function (failure) {
937
+ setProbes(function (previous) {
938
+ var next = { ...previous };
939
+ next[server.name] = { ok: false, live: false, toolCount: 0, latencyMs: null, error: failure instanceof Error ? failure.message : String(failure) };
940
+ return next;
941
+ });
942
+ });
943
+ }
944
+
782
945
  function confirmRemove() {
783
946
  var name = confirmName;
784
947
  setConfirm(null);
@@ -834,6 +997,8 @@ window.__ModuleLoader__.load({
834
997
  onToggleServer: toggleServer,
835
998
  onToggleTool: toggleTool,
836
999
  onRemove: removeServer,
1000
+ onProbe: probeServer,
1001
+ probe: probes[server.name],
837
1002
  onEdit: function (target) { setEditing(target); setMode("edit"); },
838
1003
  }));
839
1004
  });
@@ -909,6 +1074,16 @@ window.__ModuleLoader__.load({
909
1074
 
910
1075
  var topDisabled = refreshing || mode !== null;
911
1076
 
1077
+ // Master switch off: the whole host side is down, so the page offers the
1078
+ // way back instead of actions that cannot work.
1079
+ if (offline) {
1080
+ return McpH("div", { className: "mcp-mgr", "data-mcp-console-section": "", "data-mcp-console-offline": "" },
1081
+ McpH("div", { className: "mcp-mgr-head" },
1082
+ McpH("h2", { className: "mcp-mgr-title" }, t("title"))),
1083
+ McpH("p", { className: "mcp-mgr-note" }, t("sectionOffline")),
1084
+ McpH("p", { className: "mcp-ext" }, t("sectionOfflineHint")));
1085
+ }
1086
+
912
1087
  return McpH("div", { className: "mcp-mgr", "data-mcp-console-section": "" },
913
1088
  McpH("div", { className: "mcp-mgr-head" },
914
1089
  McpH("h2", { className: "mcp-mgr-title" }, t("title")),
@@ -937,6 +1112,143 @@ window.__ModuleLoader__.load({
937
1112
 
938
1113
  var mcpInject = ["slots", "locale"];
939
1114
 
1115
+ /**
1116
+ * Switch for the settings-GUI card: `role="switch"` button matching the
1117
+ * official plugin cards on that page (their own component is not exported,
1118
+ * so the shape is reproduced rather than imported).
1119
+ */
1120
+ function McpPluginSwitch(props) {
1121
+ return McpH("button", {
1122
+ type: "button",
1123
+ role: "switch",
1124
+ className: "mcp-plugin-switch",
1125
+ "aria-checked": props.checked ? "true" : "false",
1126
+ "aria-label": props.label,
1127
+ disabled: props.disabled,
1128
+ onClick: function () { props.onChange(!props.checked); },
1129
+ }, McpH("span", { className: "mcp-plugin-thumb" }));
1130
+ }
1131
+
1132
+ /**
1133
+ * The settings-GUI card body: the enabled / announceToAgent master
1134
+ * switches the host registered against the official settings provider.
1135
+ * Exported separately from the card shell so the collapsed header and the
1136
+ * fields can each be asserted on their own. A toggle writes through the
1137
+ * client settings scope (serialized, revision-fenced); external edits
1138
+ * arrive through the scope subscription and re-render live.
1139
+ */
1140
+ function McpPluginCardFields(props) {
1141
+ var t = props.t;
1142
+ var scope = props.scope;
1143
+ var useState = React.useState;
1144
+ var bumpState = React.useReducer(function (x) { return x + 1; }, 0);
1145
+ var bump = bumpState[1];
1146
+ React.useEffect(function () {
1147
+ return scope ? scope.subscribe(function () { bump(); }) : undefined;
1148
+ }, [scope]);
1149
+ var busyState = useState(null); // null | "enabled" | "announceToAgent"
1150
+ var busy = busyState[0], setBusy = busyState[1];
1151
+ var errorState = useState(null);
1152
+ var error = errorState[0], setError = errorState[1];
1153
+
1154
+ function writeFailure(failure) {
1155
+ setError(t("pluginCardWriteError") + ": " + (failure && failure.message ? failure.message : String(failure)));
1156
+ }
1157
+
1158
+ var snapshot = scope ? scope.getSnapshot() : null;
1159
+ if (!snapshot || snapshot.status === "loading") {
1160
+ return McpH("div", { className: "mcp-plugin-body" },
1161
+ McpH("div", { className: "mcp-plugin-field" },
1162
+ McpH("p", { className: "mcp-plugin-hint" }, t("pluginCardLoading"))));
1163
+ }
1164
+ if (snapshot.status === "unavailable") {
1165
+ return McpH("div", { className: "mcp-plugin-body" },
1166
+ McpH("div", { className: "mcp-plugin-field" },
1167
+ McpH("p", { className: "mcp-plugin-hint" }, t("pluginCardUnavailable"))));
1168
+ }
1169
+ var value = snapshot.value ?? {};
1170
+ var user = snapshot.user && typeof snapshot.user === "object" && !Array.isArray(snapshot.user) ? snapshot.user : {};
1171
+ var writable = snapshot.writable !== false;
1172
+
1173
+ function settle(field) {
1174
+ return [
1175
+ function () { setBusy(null); },
1176
+ function (failure) { setBusy(null); writeFailure(failure); },
1177
+ ];
1178
+ }
1179
+ function write(field, next) {
1180
+ setBusy(field);
1181
+ setError(null);
1182
+ var done = settle(field);
1183
+ scope.set(field, next).then(done[0], done[1]);
1184
+ }
1185
+ function reset(field) {
1186
+ setBusy(field);
1187
+ setError(null);
1188
+ var done = settle(field);
1189
+ scope.unset(field).then(done[0], done[1]);
1190
+ }
1191
+
1192
+ /** One boolean field: label + customized badge + per-field reset + switch, hint below. */
1193
+ function field(key, label, help) {
1194
+ var overridden = Object.prototype.hasOwnProperty.call(user, key);
1195
+ return McpH("div", { className: "mcp-plugin-field", key: key },
1196
+ McpH("div", { className: "mcp-plugin-field-head" },
1197
+ McpH("span", { className: "mcp-plugin-field-label" }, label),
1198
+ overridden ? McpH("span", { className: "mcp-plugin-badge" }, t("pluginCardOverridden")) : null,
1199
+ overridden
1200
+ ? McpH("button", {
1201
+ type: "button", className: "mcp-plugin-reset", disabled: busy !== null || !writable,
1202
+ onClick: function () { reset(key); },
1203
+ }, t("pluginCardReset"))
1204
+ : null,
1205
+ McpH(McpPluginSwitch, {
1206
+ label: label,
1207
+ checked: value[key] !== false,
1208
+ disabled: !writable || busy !== null,
1209
+ onChange: function (next) { write(key, next); },
1210
+ })),
1211
+ McpH("p", { className: "mcp-plugin-hint" }, help));
1212
+ }
1213
+
1214
+ return McpH("div", { className: "mcp-plugin-body" },
1215
+ field("enabled", t("pluginCardEnabled"), t("pluginCardEnabledHelp")),
1216
+ field("announceToAgent", t("pluginCardAnnounce"), t("pluginCardAnnounceHelp")),
1217
+ error ? McpH("p", { className: "mcp-plugin-err" }, error) : null);
1218
+ }
1219
+
1220
+ /**
1221
+ * The settings-GUI card (settings.plugin.item, keyed "mcp-console"). The
1222
+ * plugins tab lists a namespace only when its browser half also registers
1223
+ * a card under that key, so this card is what actually surfaces the
1224
+ * switches. Shape and collapse behavior follow the official cards on that
1225
+ * page: a header button carrying name, description and chevron, with the
1226
+ * fields revealed underneath.
1227
+ */
1228
+ function McpPluginCard(props) {
1229
+ var t = props.t;
1230
+ var openState = React.useState(false);
1231
+ var open = openState[0], setOpen = openState[1];
1232
+ var title = t("pluginCardTitle");
1233
+ return McpH("li", {
1234
+ className: "mcp-plugin-card",
1235
+ "data-open": open ? "true" : "false",
1236
+ "data-mcp-plugin-card": "",
1237
+ },
1238
+ McpH("button", {
1239
+ type: "button",
1240
+ className: "mcp-plugin-header",
1241
+ "aria-expanded": open ? "true" : "false",
1242
+ "aria-label": (open ? t("pluginCardCollapse") : t("pluginCardExpand")) + ": " + title,
1243
+ onClick: function () { setOpen(!open); },
1244
+ },
1245
+ McpH("span", { className: "mcp-plugin-headtext" },
1246
+ McpH("span", { className: "mcp-plugin-name" }, title),
1247
+ McpH("span", { className: "mcp-plugin-desc" }, t("pluginCardDesc"))),
1248
+ McpH(McpIcons.IconChevronDownOutline14, { size: 14, className: "mcp-plugin-chevron" })),
1249
+ open ? McpH(McpPluginCardFields, { t: t, scope: props.scope }) : null);
1250
+ }
1251
+
940
1252
  function mcpConsoleApply(ctx) {
941
1253
  ctx.effect(function () {
942
1254
  return ctx.locale.register(McpNS, { zh: mcpZh, en: mcpEn });
@@ -953,10 +1265,32 @@ window.__ModuleLoader__.load({
953
1265
  inject: function () { return { t: mcpT }; },
954
1266
  }, McpSection);
955
1267
  });
1268
+
1269
+ // Settings-GUI card (settings.plugin.item keyed "mcp-console"): the
1270
+ // host half registers the namespace against the official settings
1271
+ // provider; this browser half supplies the card the plugins tab pairs
1272
+ // with it. Deferred on the settingsScope service so hosts without the
1273
+ // settings UI keep the console working (the section above stays).
1274
+ ctx.inject(["settingsScope"], function (scope) {
1275
+ var settingsScope = scope.settingsScope.bind({ namespace: "mcp-console" });
1276
+ scope.slots.inject("settings.plugin.item", function () {
1277
+ return scope.slots.register({
1278
+ name: "settings.plugin.item",
1279
+ key: "mcp-console",
1280
+ locale: McpNS,
1281
+ inject: function () { return { t: mcpT, scope: settingsScope }; },
1282
+ }, McpPluginCard);
1283
+ });
1284
+ });
956
1285
  }
957
1286
 
958
1287
  exports.McpSection = McpSection;
1288
+ exports.McpServerItem = McpServerItem;
1289
+ exports.McpPluginCard = McpPluginCard;
1290
+ exports.McpPluginCardFields = McpPluginCardFields;
959
1291
  exports.mcpConsoleApply = mcpConsoleApply;
1292
+ exports.mcpConsoleOffline = mcpConsoleOffline;
1293
+ exports.mcpDictionaries = { zh: mcpZh, en: mcpEn };
960
1294
  exports.mcpInject = mcpInject;
961
1295
 
962
1296
  // ==== end mcp-console client body ====
package/lib/index.js CHANGED
@@ -9,10 +9,16 @@
9
9
  * registration and reconnect belongs to the official client plugin, loaded
10
10
  * one fiber per server through `ctx.plugin()`.
11
11
  *
12
- * Service wiring uses `ctx.inject(["webServer", "tools"], ...)` rather than a
13
- * static `inject` export: on a host without the web profile (no webServer)
14
- * the plugin stays dormant instead of blocking the whole dsh-pouch bundle.
12
+ * Surface wiring uses dynamic `ctx.inject(...)` scopes rather than a static
13
+ * `inject` export: on a host without the web profile (no webServer) the
14
+ * plugin stays dormant instead of blocking the whole dsh-pouch bundle. The
15
+ * same mechanism powers the settings-GUI master switches (borrowed from
16
+ * dsh-skills-mcp-manager): `enabled` tears the composition (routes + MCP
17
+ * fibers) up and down live, `announceToAgent` toggles the model-facing
18
+ * system-prompt announcement — both editable from the settings GUI through
19
+ * the official settings provider, persisted in its user document.
15
20
  */
21
+ import z from "@deepseek-ai/schemastery";
16
22
  import { Store, globalStorePath, projectStorePath } from "./store.js";
17
23
  import { Orchestrator } from "./orchestrator.js";
18
24
  import { computeSnapshot } from "./status.js";
@@ -24,8 +30,156 @@ import { existsSync, readFileSync } from "node:fs";
24
30
  /** Cordis plugin name used by loader diagnostics. */
25
31
  export const name = "mcp-console";
26
32
 
27
- export function apply(ctx) {
28
- ctx.inject(["webServer", "tools"], (scope) => {
33
+ /** Settings namespace the master switches live under (provider contract: [a-z0-9-]). */
34
+ export const MCP_CONSOLE_SETTINGS_NAMESPACE = "mcp-console";
35
+
36
+ /** Plugin config: master switches, editable from the settings GUI. */
37
+ export const Config = z.object({
38
+ /** Master switch: routes + MCP fibers + SSE (stores are never touched). */
39
+ enabled: z.boolean().default(true),
40
+ /** Register the model-facing system-prompt announcement. */
41
+ announceToAgent: z.boolean().default(true),
42
+ });
43
+
44
+ /** Resolve a raw config value (any shape) onto the switch defaults. */
45
+ export function resolveConfig(value) {
46
+ return {
47
+ enabled: value?.enabled !== false,
48
+ announceToAgent: value?.announceToAgent !== false,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Register this optional settings consumer on both the current provider API
54
+ * (`settings.installSection`, dsh-settings >= 0.1.2) and the pre-0.1.2
55
+ * `register` surface used by older hosts/tests. Same helper as the sibling
56
+ * pouch plugins — kept verbatim so behavior cannot drift between packages.
57
+ */
58
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
59
+ ctx.inject(["settings"], (settingsCtx) => {
60
+ const settings = settingsCtx.settings;
61
+ if (typeof settings?.installSection === "function") {
62
+ settings.installSection(ctx, ns, schema, entry, hooks);
63
+ return;
64
+ }
65
+ if (typeof settings?.register !== "function") return;
66
+ const scope = settings.register(ns, schema, {
67
+ base: entry,
68
+ ...(hooks.validate === void 0 ? {} : { validate: hooks.validate }),
69
+ });
70
+ hooks.setSource(() => scope.get());
71
+ settingsCtx.effect?.(() => () => {
72
+ if (ctx.fiber?.state === 4 || ctx.fiber?.state === 5) return;
73
+ hooks.setSource(() => entry);
74
+ hooks.onChange();
75
+ });
76
+ hooks.onChange();
77
+ scope.watch(() => {
78
+ if (ctx.fiber?.state === 4 || ctx.fiber?.state === 5) return;
79
+ hooks.onChange();
80
+ });
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Model-facing announcement (borrowed from dsh-skills-mcp-manager): tells
86
+ * every agent the console exists, so MCP-management requests land in the
87
+ * settings page instead of hand-edited config files.
88
+ */
89
+ const SYSTEM_PROMPT_GUIDANCE =
90
+ "本机已安装 MCP 控制台插件(dsh-plugin-mcp-console):DSH Web 设置页「MCP 服务器」分区可管理全部 MCP 服务器——" +
91
+ "新增/编辑/删除、启用/停用、逐工具开关、mcpServers JSON 导入、连接测试。启用的服务器经官方 " +
92
+ "@deepseek-ai/dsh-mcp-client 真实连接,工具名为 mcp__<服务器名>__<工具名>;被逐工具停用的工具仍会出现在工具" +
93
+ "列表中,但调用会被拒绝并提示去设置页开启。配置由插件持久化在 ~/.dsh/dsh-mcp.json,无需也不应手改 " +
94
+ "cordis.patch.yml。用户提到「MCP 服务器 / MCP 管理 / 导入 MCP / 测试 MCP 连接」时,引导其到设置页的 " +
95
+ "「MCP 服务器」分区操作。";
96
+
97
+ /** System-prompt band this announcement sits in (after plugin guidance basics). */
98
+ const ANNOUNCEMENT_ORDER = 160;
99
+
100
+ export function apply(ctx, config) {
101
+ const entry = resolveConfig(config);
102
+ let current = () => entry;
103
+
104
+ /** Live surfaces, each an inject fiber that can be torn down and rebuilt. */
105
+ let compositionFiber = null;
106
+ let announcementFiber = null;
107
+ /** Serializes surface switches behind in-flight disposals (rapid toggles). */
108
+ let syncChain = Promise.resolve();
109
+ let booted = false;
110
+
111
+ function errorMessage(error) {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
114
+
115
+ function warn(error) {
116
+ ctx.logger.warn(`mcp-console: settings sync failed: ${errorMessage(error)}`);
117
+ }
118
+
119
+ async function quietDispose(fiber) {
120
+ try {
121
+ await fiber.dispose();
122
+ } catch {
123
+ // unload races are reaped by context teardown
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Converge the live surfaces onto the resolved switches. All decisions and
129
+ * creations happen synchronously BEFORE the first await: a paused step must
130
+ * never re-read the shared fiber slots after resuming (a later step may
131
+ * have rebuilt them meanwhile), so disposals run from captured locals last.
132
+ */
133
+ async function syncStep() {
134
+ const value = resolveConfig(current());
135
+ const wantAnnouncement = value.enabled && value.announceToAgent;
136
+ const toDispose = [];
137
+ if (wantAnnouncement && !announcementFiber) {
138
+ announcementFiber = ctx.inject(["systemPrompt"], (scope) => {
139
+ scope.systemPrompt.section({
140
+ name: "plugin:mcp-console",
141
+ order: ANNOUNCEMENT_ORDER,
142
+ text: SYSTEM_PROMPT_GUIDANCE,
143
+ });
144
+ });
145
+ } else if (!wantAnnouncement && announcementFiber) {
146
+ toDispose.push(announcementFiber);
147
+ announcementFiber = null;
148
+ }
149
+ if (value.enabled && !compositionFiber) {
150
+ compositionFiber = ctx.inject(["webServer", "tools"], buildComposition);
151
+ } else if (!value.enabled && compositionFiber) {
152
+ toDispose.push(compositionFiber);
153
+ compositionFiber = null;
154
+ }
155
+ for (const fiber of toDispose) {
156
+ await quietDispose(fiber);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The first pass runs synchronously up to its first await (surface
162
+ * creation), so the composition exists by the time apply() returns even
163
+ * when the pre-0.1.2 settings helper fires onChange during registration.
164
+ * Later passes serialize fully behind the previous step's disposals, so a
165
+ * rapid off/on never overlaps two live compositions on one route prefix.
166
+ */
167
+ function sync() {
168
+ if (!booted) {
169
+ booted = true;
170
+ syncChain = Promise.resolve(syncStep()).catch(warn);
171
+ return;
172
+ }
173
+ syncChain = syncChain.then(syncStep).catch(warn);
174
+ }
175
+
176
+ /**
177
+ * The console's whole host-side surface: HTTP API + SSE, the per-tool
178
+ * guard, status observation and the fiber ledger. Everything is created
179
+ * from the injected scope, so disposing the inject fiber tears it all
180
+ * down (routes closed, every MCP fiber disposed, guard lifted).
181
+ */
182
+ function buildComposition(scope) {
29
183
  const globalStore = new Store(globalStorePath());
30
184
  const orchestrator = new Orchestrator(scope, { globalStore, onChange: () => notify() });
31
185
 
@@ -61,7 +215,11 @@ export function apply(ctx) {
61
215
  // 容错降级:保持 global-only
62
216
  }
63
217
 
64
- const routes = registerRoutes(scope, { orchestrator, uiStore: globalStore });
218
+ const routes = registerRoutes(scope, {
219
+ orchestrator,
220
+ uiStore: globalStore,
221
+ getPluginConfig: () => resolveConfig(current()),
222
+ });
65
223
  const notify = () => routes.notify();
66
224
 
67
225
  // Per-tool disable (mcp-manager-gui-spec §3.4): a plain-context guard
@@ -96,7 +254,18 @@ export function apply(ctx) {
96
254
  routes.dispose();
97
255
  };
98
256
  }, "mcp-console: composition");
257
+ }
258
+
259
+ // Registered AFTER the surface state exists: the pre-0.1.2 helper fires
260
+ // onChange synchronously during registration, which must find sync() ready.
261
+ installSettingsSection(ctx, MCP_CONSOLE_SETTINGS_NAMESPACE, Config, entry, {
262
+ setSource: (source) => {
263
+ current = source;
264
+ },
265
+ onChange: () => sync(),
99
266
  });
267
+
268
+ sync();
100
269
  }
101
270
 
102
271
  export { Store, globalStorePath, projectStorePath } from "./store.js";
@@ -42,6 +42,12 @@ export const FiberState = {
42
42
  /** Official initial sync window (default toolCallTimeoutMs) plus slack. */
43
43
  export const CONNECT_GRACE_MS = 75000;
44
44
 
45
+ /**
46
+ * Hard budget for one side-fiber probe (ms). Generous vs the official
47
+ * initial-sync window: a stdio server may need a cold npx/uvx download.
48
+ */
49
+ export const PROBE_TIMEOUT_MS = 15000;
50
+
45
51
  /** Error with a stable machine-readable code mapped to an HTTP status. */
46
52
  export class ConsoleError extends Error {
47
53
  /**
@@ -182,6 +188,15 @@ export class Orchestrator {
182
188
  toolsOf(name, currentNames) {
183
189
  const current = currentNames ?? this.toolNamesSnapshot();
184
190
  const entry = this.ledger.get(name);
191
+ return this.attributeTools(name, current, entry ? entry.toolNames : new Set());
192
+ }
193
+
194
+ /**
195
+ * Attribute live tool names to one server from a registry snapshot plus a
196
+ * load-time diff set (prefix first, then unclaimed diff names). Shared by
197
+ * the snapshot path and the side-fiber probe, which owns no ledger entry.
198
+ */
199
+ attributeTools(name, current, captured) {
185
200
  const prefix = `mcp__${name}__`;
186
201
  const foreignPrefixes = [];
187
202
  for (const other of this.liveServerNames()) {
@@ -193,7 +208,7 @@ export class Orchestrator {
193
208
  names.push(toolName);
194
209
  continue;
195
210
  }
196
- if (!entry || !entry.toolNames.has(toolName)) continue;
211
+ if (!captured.has(toolName)) continue;
197
212
  if (foreignPrefixes.some((foreign) => toolName.startsWith(foreign))) continue;
198
213
  names.push(toolName);
199
214
  }
@@ -446,6 +461,88 @@ export class Orchestrator {
446
461
  });
447
462
  }
448
463
 
464
+ /**
465
+ * Side-fiber connectivity probe ("Test connection", borrowed from
466
+ * dsh-skills-mcp-manager): load ONE throwaway official-client fiber with
467
+ * `failOnStartupError: true`, await its startup handshake, count the tools
468
+ * it registered, then always dispose it. Zero MCP protocol code — the
469
+ * handshake itself belongs to the official client, so the console's red
470
+ * line holds.
471
+ *
472
+ * A server whose fiber is currently live cannot be re-probed (the official
473
+ * client reserves the serverName namespace per module instance); for those
474
+ * the probe honestly reports the live status instead (`live: true`).
475
+ *
476
+ * Serialized through the mutation chain: the probe owns a real fiber and
477
+ * must never race a load/dispose of the same namespace.
478
+ *
479
+ * @param {string} name stored server name
480
+ * @param {object} [options]
481
+ * @param {number} [options.timeoutMs] hard probe budget (default 15s)
482
+ * @returns {Promise<{ok: boolean, live: boolean, toolCount: number, latencyMs: number|null, error: string|null}>}
483
+ */
484
+ probe(name, { timeoutMs = PROBE_TIMEOUT_MS } = {}) {
485
+ return this.run(async () => {
486
+ const stored = this.storedServers().get(name);
487
+ if (!stored) throw new ConsoleError("not_found", `unknown server "${name}"`);
488
+ const { config, error } = validateServerConfig(stored);
489
+ if (error) {
490
+ return { ok: false, live: false, toolCount: 0, latencyMs: 0, error };
491
+ }
492
+ const entry = this.ledger.get(name);
493
+ if (entry && entry.fiber && entry.fiber.state !== FiberState.DISPOSED) {
494
+ // Namespace reserved by the live fiber: report, do not collide.
495
+ const tools = this.toolsOf(name);
496
+ const ok = !entry.error && entry.fiber.state === FiberState.ACTIVE && tools.length > 0;
497
+ return {
498
+ ok,
499
+ live: true,
500
+ toolCount: tools.length,
501
+ latencyMs: null,
502
+ error: ok ? null : (entry.error ?? "live but no tools registered"),
503
+ };
504
+ }
505
+ const started = Date.now();
506
+ let fiber = null;
507
+ let timer = null;
508
+ try {
509
+ if (!this.mcpClient) this.mcpClient = await resolveMcpClient();
510
+ // force failOnStartupError: the probe must hear about a failed handshake
511
+ const probeConfig = { ...toClientConfig(config), failOnStartupError: true };
512
+ const before = this.toolNamesSnapshot();
513
+ fiber = this.ctx.plugin(this.mcpClient, probeConfig);
514
+ await new Promise((resolve, reject) => {
515
+ timer = setTimeout(
516
+ () => reject(new Error(`probe timed out after ${timeoutMs}ms`)),
517
+ timeoutMs,
518
+ );
519
+ Promise.resolve(fiber).then(resolve, reject);
520
+ });
521
+ const after = this.toolNamesSnapshot();
522
+ const captured = new Set([...after].filter((toolName) => !before.has(toolName)));
523
+ const toolCount = this.attributeTools(name, after, captured).length;
524
+ return { ok: true, live: false, toolCount, latencyMs: Date.now() - started, error: null };
525
+ } catch (probeError) {
526
+ return {
527
+ ok: false,
528
+ live: false,
529
+ toolCount: 0,
530
+ latencyMs: Date.now() - started,
531
+ error: errorMessage(probeError),
532
+ };
533
+ } finally {
534
+ if (timer) clearTimeout(timer);
535
+ if (fiber && fiber.state !== FiberState.DISPOSED) {
536
+ try {
537
+ await fiber.dispose();
538
+ } catch {
539
+ // probe already reported; context teardown reaps the rest
540
+ }
541
+ }
542
+ }
543
+ });
544
+ }
545
+
449
546
  /**
450
547
  * Panel refresh (mcp-manager-gui-spec §3.1): re-examine health and tool
451
548
  * lists. Managed servers that read as failed (startup error, or connected
package/lib/routes.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * | POST | /servers/:name/enable | enable + assemble |
13
13
  * | POST | /servers/:name/disable | dispose (config kept) |
14
14
  * | POST | /servers/:name/reconnect | fiber.restart() |
15
+ * | POST | /servers/:name/probe | side-fiber connectivity test |
15
16
  * | POST | /import | mcpServers JSON import |
16
17
  * | GET | /events | SSE status stream |
17
18
  * | GET | /config | UI config (non-secret) |
@@ -20,6 +21,10 @@
20
21
  * Loopback fence: every mutating and secret-bearing route answers 403 to
21
22
  * non-loopback callers; GET /config (UI placement only) is open so a LAN
22
23
  * browser can at least read placement while the data routes stay local.
24
+ * "Loopback" is four checks at once (hardened after dsh-skills-mcp-manager):
25
+ * a loopback socket peer, a Host header naming a loopback origin (DNS
26
+ * rebinding fence), no `sec-fetch-site: cross-site` browser marker, and —
27
+ * when an Origin header is present — an Origin matching the Host.
23
28
  */
24
29
  import { computeSnapshot } from "./status.js";
25
30
  import { ConsoleError } from "./orchestrator.js";
@@ -35,6 +40,9 @@ const STATUS_BY_CODE = { invalid: 400, conflict: 409, not_found: 404, internal:
35
40
 
36
41
  const LOOPBACK_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
37
42
 
43
+ /** Host header hostnames accepted as loopback (IPv6 keeps its brackets). */
44
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
45
+
38
46
  /**
39
47
  * Register the API on the webserver.
40
48
  *
@@ -42,9 +50,11 @@ const LOOPBACK_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
42
50
  * @param {object} services
43
51
  * @param {import("./orchestrator.js").Orchestrator} services.orchestrator
44
52
  * @param {import("./store.js").Store} services.uiStore store holding the `ui` section
53
+ * @param {() => {enabled: boolean, announceToAgent: boolean}} [services.getPluginConfig]
54
+ * resolved master switches (settings-GUI backed), surfaced in /health
45
55
  * @returns {{ dispose: () => void, notify: () => void }} route handle
46
56
  */
47
- export function registerRoutes(ctx, { orchestrator, uiStore }) {
57
+ export function registerRoutes(ctx, { orchestrator, uiStore, getPluginConfig }) {
48
58
  const sseClients = new Set();
49
59
  let heartbeat = null;
50
60
  let notifyTimer = null;
@@ -120,6 +130,7 @@ export function registerRoutes(ctx, { orchestrator, uiStore }) {
120
130
  respond(res, 200, {
121
131
  ok: true,
122
132
  plugin: "mcp-console",
133
+ config: getPluginConfig ? getPluginConfig() : null,
123
134
  globalStore: orchestrator.globalStore.path,
124
135
  projectStore: orchestrator.projectStore?.path ?? null,
125
136
  managed: [...orchestrator.ledger.keys()].sort(),
@@ -219,6 +230,7 @@ export function registerRoutes(ctx, { orchestrator, uiStore }) {
219
230
  if (action === "enable") respond(res, 200, await orchestrator.setEnabled(name, true));
220
231
  else if (action === "disable") respond(res, 200, await orchestrator.setEnabled(name, false));
221
232
  else if (action === "reconnect") respond(res, 200, await orchestrator.reconnect(name));
233
+ else if (action === "probe") respond(res, 200, await orchestrator.probe(name));
222
234
  else respond(res, 404, { error: { code: "not_found", message: `unknown action "${action}"` } });
223
235
  }
224
236
 
@@ -256,9 +268,43 @@ export function registerRoutes(ctx, { orchestrator, uiStore }) {
256
268
  return { dispose, notify };
257
269
  }
258
270
 
259
- function isLoopback(req) {
271
+ /**
272
+ * Loopback fence, four layers deep (borrowed from dsh-skills-mcp-manager):
273
+ * 1. the socket peer is a loopback address;
274
+ * 2. the Host header names a loopback origin — a DNS-rebindable name
275
+ * (e.g. attacker.example) resolves publicly but the browser sends it in
276
+ * Host; rejecting non-loopback Hosts closes that hole;
277
+ * 3. no `sec-fetch-site: cross-site` marker (browsers always send it on
278
+ * cross-site fetches; non-browser clients simply omit it);
279
+ * 4. when an Origin is present it must match the Host (same-origin) —
280
+ * Origin-less clients (curl, EventSource in older stacks) pass.
281
+ *
282
+ * @param {import("node:http").IncomingMessage} req
283
+ */
284
+ export function isLoopback(req) {
260
285
  const address = req?.socket?.remoteAddress;
261
- return typeof address === "string" && LOOPBACK_ADDRESSES.has(address);
286
+ if (typeof address !== "string" || !LOOPBACK_ADDRESSES.has(address)) return false;
287
+ const host = req?.headers?.host;
288
+ if (typeof host !== "string" || host.length === 0) return false;
289
+ let hostUrl;
290
+ try {
291
+ hostUrl = new URL(`http://${host}`);
292
+ } catch {
293
+ return false;
294
+ }
295
+ if (!LOOPBACK_HOSTNAMES.has(hostUrl.hostname)) return false;
296
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
297
+ const origin = req.headers.origin;
298
+ if (origin !== undefined) {
299
+ let originUrl;
300
+ try {
301
+ originUrl = new URL(origin);
302
+ } catch {
303
+ return false;
304
+ }
305
+ if (originUrl.host !== hostUrl.host) return false;
306
+ }
307
+ return true;
262
308
  }
263
309
 
264
310
  function respond(res, status, body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moon16u/dsh-plugin-mcp-console",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Web GUI console for MCP servers: manages the official @deepseek-ai/dsh-mcp-client at runtime — add/edit/enable/disable/reconnect/delete servers, live status, mcpServers JSON import. No MCP protocol code: connection and tool registration are 100% the official client, resolved from the running DSH profile.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -28,7 +28,6 @@
28
28
  "client": {
29
29
  "inject": [
30
30
  "@deepseek-ai/dsh-client-locale",
31
- "@deepseek-ai/dsh-client-runtime",
32
31
  "@deepseek-ai/dsh-client-ui-conversation",
33
32
  "@deepseek-ai/dsh-client-ui-slots"
34
33
  ],
@@ -36,10 +35,12 @@
36
35
  }
37
36
  },
38
37
  "peerDependencies": {
39
- "@deepseek-ai/cordis": "^4.0.1",
40
- "@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
41
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
42
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
38
+ "@deepseek-ai/cordis": "^4.0.2",
39
+ "@deepseek-ai/dsh-mcp-client": "^0.1.2-rc.1",
40
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
41
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
42
+ "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
43
+ "@deepseek-ai/schemastery": "^3.18.2"
43
44
  },
44
45
  "peerDependenciesMeta": {
45
46
  "@deepseek-ai/dsh-mcp-client": {
@@ -48,6 +49,9 @@
48
49
  "@deepseek-ai/dsh-host-webserver": {
49
50
  "optional": true
50
51
  },
52
+ "@deepseek-ai/dsh-settings": {
53
+ "optional": true
54
+ },
51
55
  "@deepseek-ai/dsh-tools": {
52
56
  "optional": true
53
57
  }