@moon16u/dsh-plugin-mcp-console 0.1.2 → 0.2.1
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 +11 -2
- package/README.zh-CN.md +11 -2
- package/lib/client.js +347 -13
- package/lib/index.js +175 -6
- package/lib/orchestrator.js +98 -1
- package/lib/routes.js +49 -3
- package/lib/yamlMigrator.js +12 -3
- package/package.json +14 -6
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 #
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
345
|
-
*
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
700
|
-
|
|
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)
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
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, {
|
|
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";
|
package/lib/orchestrator.js
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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/lib/yamlMigrator.js
CHANGED
|
@@ -343,9 +343,18 @@ export async function ingestProfileMcpEntries({ store, yamlPath }) {
|
|
|
343
343
|
if (removedRanges.has(index)) continue;
|
|
344
344
|
kept.push(lines[index]);
|
|
345
345
|
}
|
|
346
|
-
// trim: collapse the blank-line runs left by removals inside the document
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
// trim: collapse the blank-line runs left by removals inside the document.
|
|
347
|
+
// A patch layer must remain a top-level YAML array: when the trim removes
|
|
348
|
+
// the last list item (comment-only remainder), append an explicit `[]` so
|
|
349
|
+
// dsh-app-boot's parsePatchList (which rejects a non-array document) does
|
|
350
|
+
// not crash the next boot. Only a column-0 `- ` counts: nested lines and
|
|
351
|
+
// indented scalars are not top-level items.
|
|
352
|
+
const hasTopLevelItem = kept.some((line) => /^-\s/.test(line));
|
|
353
|
+
const cleaned = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
354
|
+
const output = hasTopLevelItem
|
|
355
|
+
? cleaned + "\n"
|
|
356
|
+
: (cleaned.length > 0 ? cleaned + "\n\n" : "") + "[]\n";
|
|
357
|
+
writeFileSync(targetPath, output, "utf8");
|
|
349
358
|
|
|
350
359
|
return { ingested, skipped, cleanedYaml: true };
|
|
351
360
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moon16u/dsh-plugin-mcp-console",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
40
|
-
"@deepseek-ai/dsh-mcp-client": "^0.1.
|
|
41
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
42
|
-
"@deepseek-ai/dsh-
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
39
|
+
"@deepseek-ai/dsh-mcp-client": "^0.1.5-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-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
|
}
|
|
@@ -60,6 +64,10 @@
|
|
|
60
64
|
"access": "public",
|
|
61
65
|
"registry": "https://registry.npmjs.org/"
|
|
62
66
|
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"react": "18.2.0",
|
|
69
|
+
"react-dom": "18.2.0"
|
|
70
|
+
},
|
|
63
71
|
"scripts": {
|
|
64
72
|
"test": "node --test test/*.test.mjs",
|
|
65
73
|
"sync-root-client": "node scripts/sync-root-client.mjs"
|