@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +351 -2
- package/README.zh.md +213 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/adapters.js +346 -0
- package/lib/canonical.js +488 -0
- package/lib/client.js +1151 -0
- package/lib/dsh-patch.js +329 -0
- package/lib/dsh.js +259 -0
- package/lib/index.js +495 -0
- package/lib/ops.js +325 -0
- package/lib/probe.js +252 -0
- package/lib/scan.js +353 -0
- package/lib/store.js +257 -0
- package/lib/surgical.js +581 -0
- package/lib/toggle.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client side of the MCP manager: one settings page listing every MCP server
|
|
3
|
+
* found anywhere on this machine, grouped by the file that declares it.
|
|
4
|
+
*
|
|
5
|
+
* The page reads and writes only through the Host routes it registers, so the
|
|
6
|
+
* agent tool and this UI share one implementation. Nothing here scans the
|
|
7
|
+
* filesystem: a browser cannot, and pretending otherwise would produce a second
|
|
8
|
+
* source of truth.
|
|
9
|
+
*
|
|
10
|
+
* The layout deliberately mirrors the skills page: a title row with actions, a
|
|
11
|
+
* four-cell summary strip, a filter row, then one bordered card per source with
|
|
12
|
+
* a collapsible head and a flat list of rows. Any deviation from that system is
|
|
13
|
+
* a bug, not a preference — the two pages sit next to each other in the same
|
|
14
|
+
* settings list.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: '@oh-agent/dsh-mcp-manager',
|
|
19
|
+
factory(require) {
|
|
20
|
+
const React = require('react');
|
|
21
|
+
const h = React.createElement;
|
|
22
|
+
const { useState, useEffect, useMemo, useCallback, useRef } = React;
|
|
23
|
+
|
|
24
|
+
const NS = 'mcp-manager';
|
|
25
|
+
const BASE = '/api/dsh-mcp-manager';
|
|
26
|
+
|
|
27
|
+
/* ------------------------------------------------------------- locale -- */
|
|
28
|
+
|
|
29
|
+
const DICT = {
|
|
30
|
+
en: {
|
|
31
|
+
title: 'MCP',
|
|
32
|
+
desc: 'Every Model Context Protocol server on this machine, grouped by the file that declares it. The switch mounts a server in DeepSeek Harness — no other tool’s configuration is ever modified.',
|
|
33
|
+
'btn.refresh': 'Refresh',
|
|
34
|
+
'btn.reveal': 'Show values',
|
|
35
|
+
'btn.hide': 'Mask values',
|
|
36
|
+
'btn.probeAll': 'Test all',
|
|
37
|
+
'btn.probe': 'Test',
|
|
38
|
+
'btn.close': 'Close',
|
|
39
|
+
'btn.sync': 'Copy to…',
|
|
40
|
+
'btn.snippet': 'YAML row',
|
|
41
|
+
'btn.history': 'Recent writes',
|
|
42
|
+
'btn.restore': 'Restore',
|
|
43
|
+
'btn.copy': 'Copy',
|
|
44
|
+
'btn.copied': 'Copied',
|
|
45
|
+
'btn.cancel': 'Cancel',
|
|
46
|
+
'state.loading': 'Scanning local configuration…',
|
|
47
|
+
'state.empty': 'No MCP servers configured in any detected tool.',
|
|
48
|
+
'state.failed': 'Scan failed',
|
|
49
|
+
'state.noMatch': 'No server matches these filters.',
|
|
50
|
+
'summary.servers': 'servers',
|
|
51
|
+
'summary.files': 'files',
|
|
52
|
+
'summary.mounted': 'mounted in DSH',
|
|
53
|
+
'summary.secrets': 'literal credentials',
|
|
54
|
+
'filter.search': 'Filter by name, command or path',
|
|
55
|
+
'filter.toolAll': 'All tools',
|
|
56
|
+
'filter.statusAll': 'All servers',
|
|
57
|
+
'filter.statusMounted': 'Mounted in DSH',
|
|
58
|
+
'filter.statusUnmounted': 'Not mounted',
|
|
59
|
+
'label.project': 'project',
|
|
60
|
+
'label.global': 'global',
|
|
61
|
+
'label.profile': 'profile',
|
|
62
|
+
'label.readOnly': 'read-only',
|
|
63
|
+
'label.pending': 'activating',
|
|
64
|
+
'label.mounted': 'mounted',
|
|
65
|
+
'label.off': 'off',
|
|
66
|
+
'label.shared': 'in {count} files',
|
|
67
|
+
'switch.title': 'Mount in DeepSeek Harness (this never edits {client})',
|
|
68
|
+
'detail.targets': 'Declared in',
|
|
69
|
+
'detail.definition': 'Definition',
|
|
70
|
+
'detail.snippetTitle': 'Paste into a cordis composition to add the server:',
|
|
71
|
+
'detail.secretWarn': 'This file stores credentials as literal text. Consider a variable reference instead.',
|
|
72
|
+
'detail.history': 'Recent writes',
|
|
73
|
+
'detail.noHistory': 'No writes recorded yet.',
|
|
74
|
+
'detail.emptyFile': 'This file exists but declares no servers yet.',
|
|
75
|
+
'probe.ok': 'healthy · {tools} tools · {ms}ms',
|
|
76
|
+
'probe.failed': 'failed',
|
|
77
|
+
'probe.tools': '{count} tools',
|
|
78
|
+
'banner.secrets': '{count} server(s) store credentials as literal text in a configuration file that may be committed.',
|
|
79
|
+
'banner.parse': '{count} file(s) could not be parsed.',
|
|
80
|
+
'confirm.sync': 'Copy “{name}” into {count} other tool(s)?',
|
|
81
|
+
'confirm.reveal': 'Show credential values in clear text?',
|
|
82
|
+
'error.title': 'Something went wrong',
|
|
83
|
+
'error.conflict': 'This file was changed by the tool itself since the scan. Refresh to reload it, then try again — nothing was written.',
|
|
84
|
+
'error.projectScope': 'A project file is usually committed, so a plaintext credential is refused there. Write to the global scope instead, or replace the value with a variable reference.',
|
|
85
|
+
'error.mount': 'DeepSeek Harness could not mount this server. The definition stays as it is in its own file.',
|
|
86
|
+
'toast.mounted': 'Mounted “{name}” in DeepSeek Harness.',
|
|
87
|
+
'toast.unmounted': 'Unmounted “{name}” from DeepSeek Harness.',
|
|
88
|
+
'toast.mountPending': '“{name}” was written to the profile patch. Restart DeepSeek Harness to activate it.',
|
|
89
|
+
'toast.mounting': '“{name}” is being activated by DeepSeek Harness — its tools appear in a moment.',
|
|
90
|
+
'toast.restored': 'File restored.',
|
|
91
|
+
'toast.copied': 'Copied to {count} tool(s).',
|
|
92
|
+
'sync.none': 'No other tool can accept this definition.',
|
|
93
|
+
'sync.title': 'Copy “{name}” into',
|
|
94
|
+
},
|
|
95
|
+
zh: {
|
|
96
|
+
title: 'MCP',
|
|
97
|
+
desc: '本机所有 Model Context Protocol 服务器,按声明它的配置文件分组。开关用于把服务器挂载到 DeepSeek Harness —— 不会修改任何其他工具的配置。',
|
|
98
|
+
'btn.refresh': '刷新',
|
|
99
|
+
'btn.reveal': '显示明文',
|
|
100
|
+
'btn.hide': '隐藏明文',
|
|
101
|
+
'btn.probeAll': '全部测试',
|
|
102
|
+
'btn.probe': '测试',
|
|
103
|
+
'btn.close': '关闭',
|
|
104
|
+
'btn.sync': '复制到…',
|
|
105
|
+
'btn.snippet': 'YAML 行',
|
|
106
|
+
'btn.history': '最近写入',
|
|
107
|
+
'btn.restore': '还原',
|
|
108
|
+
'btn.copy': '复制',
|
|
109
|
+
'btn.copied': '已复制',
|
|
110
|
+
'btn.cancel': '取消',
|
|
111
|
+
'state.loading': '正在扫描本机配置…',
|
|
112
|
+
'state.empty': '已检测的工具中都没有配置 MCP 服务器。',
|
|
113
|
+
'state.failed': '扫描失败',
|
|
114
|
+
'state.noMatch': '没有符合筛选条件的服务器。',
|
|
115
|
+
'summary.servers': '个服务器',
|
|
116
|
+
'summary.files': '个文件',
|
|
117
|
+
'summary.mounted': '已挂载到 DSH',
|
|
118
|
+
'summary.secrets': '处明文凭据',
|
|
119
|
+
'filter.search': '按名称、命令或路径筛选',
|
|
120
|
+
'filter.toolAll': '全部工具',
|
|
121
|
+
'filter.statusAll': '全部服务器',
|
|
122
|
+
'filter.statusMounted': '已挂载到 DSH',
|
|
123
|
+
'filter.statusUnmounted': '未挂载',
|
|
124
|
+
'label.project': '项目',
|
|
125
|
+
'label.global': '全局',
|
|
126
|
+
'label.profile': 'profile',
|
|
127
|
+
'label.readOnly': '只读',
|
|
128
|
+
'label.pending': '正在激活',
|
|
129
|
+
'label.mounted': '已挂载',
|
|
130
|
+
'label.off': '未启用',
|
|
131
|
+
'label.shared': '出现在 {count} 个文件',
|
|
132
|
+
'switch.title': '挂载到 DeepSeek Harness(不会改动 {client} 本身)',
|
|
133
|
+
'detail.targets': '声明位置',
|
|
134
|
+
'detail.definition': '定义',
|
|
135
|
+
'detail.snippetTitle': '粘贴到 cordis 组合文件即可加入该服务器:',
|
|
136
|
+
'detail.secretWarn': '该文件以明文保存凭据,建议改为变量引用。',
|
|
137
|
+
'detail.history': '最近写入',
|
|
138
|
+
'detail.noHistory': '暂无写入记录。',
|
|
139
|
+
'detail.emptyFile': '该文件存在,但还没有声明任何服务器。',
|
|
140
|
+
'probe.ok': '正常 · {tools} 个工具 · {ms}ms',
|
|
141
|
+
'probe.failed': '失败',
|
|
142
|
+
'probe.tools': '{count} 个工具',
|
|
143
|
+
'banner.secrets': '有 {count} 个服务器把凭据以明文保存在可能被提交的配置文件里。',
|
|
144
|
+
'banner.parse': '有 {count} 个文件无法解析。',
|
|
145
|
+
'confirm.sync': '把 “{name}” 复制到 {count} 个其他工具?',
|
|
146
|
+
'confirm.reveal': '以明文显示凭据?',
|
|
147
|
+
'error.title': '出错了',
|
|
148
|
+
'error.conflict': '扫描之后该文件已被工具自身改动。请刷新后重试 —— 本次没有写入任何内容。',
|
|
149
|
+
'error.projectScope': '项目级配置通常会被提交到仓库,因此不允许写入明文凭据。请改为写入全局作用域,或用变量引用代替明文。',
|
|
150
|
+
'error.mount': 'DeepSeek Harness 无法挂载这个服务器。它在自己文件里的定义保持不变。',
|
|
151
|
+
'toast.mounted': '已把 “{name}” 挂载到 DeepSeek Harness。',
|
|
152
|
+
'toast.unmounted': '已从 DeepSeek Harness 卸载 “{name}”。',
|
|
153
|
+
'toast.mountPending': '“{name}” 已写入 profile 补丁,重启 DeepSeek Harness 后生效。',
|
|
154
|
+
'toast.mounting': 'DeepSeek Harness 正在激活 “{name}”,工具稍后即可调用。',
|
|
155
|
+
'toast.restored': '文件已还原。',
|
|
156
|
+
'toast.copied': '已复制到 {count} 个工具。',
|
|
157
|
+
'sync.none': '没有其他工具能接受这个定义。',
|
|
158
|
+
'sync.title': '把 “{name}” 复制到',
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function makeT(getActive) {
|
|
163
|
+
return function t(key, params) {
|
|
164
|
+
const locale = getActive();
|
|
165
|
+
const table = DICT[locale] ?? DICT.en;
|
|
166
|
+
let text = table[key] ?? DICT.en[key] ?? key;
|
|
167
|
+
if (params) {
|
|
168
|
+
for (const [name, value] of Object.entries(params)) {
|
|
169
|
+
text = text.split(`{${name}}`).join(String(value));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return text;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ---------------------------------------------------------------- api -- */
|
|
177
|
+
|
|
178
|
+
async function api(action, body) {
|
|
179
|
+
const response = await fetch(`${BASE}/${action}`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'content-type': 'application/json', 'x-dsh-mcp-manager': '1' },
|
|
182
|
+
body: JSON.stringify(body ?? {}),
|
|
183
|
+
});
|
|
184
|
+
let payload = null;
|
|
185
|
+
try {
|
|
186
|
+
payload = await response.json();
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(`non-JSON response (HTTP ${response.status})`);
|
|
189
|
+
}
|
|
190
|
+
if (!response.ok || !payload || payload.ok === false) {
|
|
191
|
+
const reason = payload && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
|
192
|
+
const error = new Error(reason);
|
|
193
|
+
error.code = payload?.code;
|
|
194
|
+
error.hint = payload?.hint;
|
|
195
|
+
error.conflict = payload?.conflict;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
return payload.data;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function fetchState(reveal) {
|
|
202
|
+
const response = await fetch(`${BASE}/state${reveal ? '?reveal=1' : ''}`, { cache: 'no-store' });
|
|
203
|
+
const payload = await response.json();
|
|
204
|
+
if (!response.ok || !payload || payload.ok === false) throw new Error(payload?.error ?? `HTTP ${response.status}`);
|
|
205
|
+
return payload.data;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* -------------------------------------------------------------- styles -- */
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
* Every rule below is a transcription of the skills page stylesheet with the
|
|
212
|
+
* class prefix changed. Where a value differs it is because the content
|
|
213
|
+
* differs (a file path instead of a git ref), never the visual language.
|
|
214
|
+
*/
|
|
215
|
+
const CSS = `
|
|
216
|
+
.mcpm-root { box-sizing: border-box; display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 14px;
|
|
217
|
+
padding: 2px 0 36px; color: var(--dsw-alias-label-primary); font-family: inherit; }
|
|
218
|
+
/*
|
|
219
|
+
* The container query context lives on an INNER wrapper, not on .mcpm-root.
|
|
220
|
+
* container-type implies layout containment, and a layout-contained element
|
|
221
|
+
* becomes the containing block for its position:fixed descendants — the detail
|
|
222
|
+
* dialog would then be sized to this column and scroll away with it instead of
|
|
223
|
+
* covering the viewport.
|
|
224
|
+
*/
|
|
225
|
+
.mcpm-main { display: flex; min-width: 0; flex-direction: column; gap: 14px; container-type: inline-size; }
|
|
226
|
+
|
|
227
|
+
.mcpm-head { display: flex; flex-direction: column; gap: 16px; }
|
|
228
|
+
.mcpm-title-row { display: flex; align-items: center; gap: 8px 12px; min-width: 0; flex-wrap: wrap; }
|
|
229
|
+
.mcpm-title-block { min-width: 0; flex: 1 1 auto; }
|
|
230
|
+
.mcpm-h1 { margin: 0; font-size: 24px; line-height: 32px; font-weight: 600; letter-spacing: -.4px; white-space: nowrap; }
|
|
231
|
+
.mcpm-desc { margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 14px; line-height: 22px; }
|
|
232
|
+
.mcpm-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; flex: none; margin-left: auto; }
|
|
233
|
+
|
|
234
|
+
.mcpm-btn { appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
|
|
235
|
+
gap: 6px; height: 30px; padding: 0 11px; border: 0.5px solid var(--dsw-alias-border-l2);
|
|
236
|
+
border-radius: var(--dsw-radius-sm, 8px); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
237
|
+
font: inherit; font-size: 13px; line-height: 20px; white-space: nowrap; cursor: pointer;
|
|
238
|
+
transition: border-color 120ms ease, background 120ms ease, color 120ms ease; }
|
|
239
|
+
.mcpm-btn:hover:not(:disabled) { border-color: var(--dsw-alias-border-l3, #555); }
|
|
240
|
+
.mcpm-btn:disabled { opacity: .5; cursor: default; }
|
|
241
|
+
.mcpm-btn:focus-visible { outline: var(--dsw-focus-ring-width, 1px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
|
|
242
|
+
.mcpm-btn.sm { height: 24px; padding: 0 8px; font-size: 12px; border-radius: var(--dsw-radius-sm, 6px); }
|
|
243
|
+
.mcpm-btn.quiet { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
|
|
244
|
+
.mcpm-btn.quiet:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
|
|
245
|
+
.mcpm-btn.primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: #fff; }
|
|
246
|
+
.mcpm-btn.primary:hover:not(:disabled) { border-color: var(--dsw-alias-brand-primary); opacity: .88; }
|
|
247
|
+
|
|
248
|
+
.mcpm-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden;
|
|
249
|
+
border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px); background: var(--dsw-alias-bg-layer-1); }
|
|
250
|
+
.mcpm-stat { min-width: 0; overflow: hidden; padding: 4px 10px; border-right: 0.5px solid var(--dsw-alias-border-l1);
|
|
251
|
+
font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
|
252
|
+
.mcpm-stat:last-child { border-right: 0; }
|
|
253
|
+
.mcpm-stat strong { margin-right: 4px; color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 650; }
|
|
254
|
+
@container (max-width: 620px) {
|
|
255
|
+
.mcpm-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
256
|
+
.mcpm-stat:nth-child(2) { border-right: 0; }
|
|
257
|
+
.mcpm-stat:nth-child(-n+2) { border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
|
|
258
|
+
}
|
|
259
|
+
@container (max-width: 380px) {
|
|
260
|
+
.mcpm-summary { grid-template-columns: 1fr; }
|
|
261
|
+
.mcpm-stat { border-right: 0; border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
|
|
262
|
+
.mcpm-stat:last-child { border-bottom: 0; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.mcpm-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; width: 100%; min-width: 0; }
|
|
266
|
+
.mcpm-search { box-sizing: border-box; flex: 1 1 200px; min-width: 0; height: 30px; padding: 0 11px;
|
|
267
|
+
border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm, 8px);
|
|
268
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; }
|
|
269
|
+
.mcpm-search::placeholder { color: var(--dsw-alias-label-tertiary); }
|
|
270
|
+
.mcpm-search:focus-visible { outline: none; border-color: var(--dsw-alias-brand-primary); }
|
|
271
|
+
.mcpm-select { box-sizing: border-box; flex: 0 0 auto; max-width: 200px; height: 30px; padding: 0 8px;
|
|
272
|
+
border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm, 8px);
|
|
273
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; cursor: pointer; }
|
|
274
|
+
.mcpm-select:focus-visible { outline: none; border-color: var(--dsw-alias-brand-primary); }
|
|
275
|
+
|
|
276
|
+
.mcpm-sources { display: flex; flex-direction: column; gap: 9px; }
|
|
277
|
+
.mcpm-source { flex-shrink: 0; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l2);
|
|
278
|
+
border-radius: var(--dsw-radius-xl, 20px); background: transparent; }
|
|
279
|
+
.mcpm-source-head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 8px 12px; }
|
|
280
|
+
.mcpm-source-toggle { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow: hidden;
|
|
281
|
+
padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
282
|
+
.mcpm-source-toggle:focus-visible { outline: var(--dsw-focus-ring-width, 1px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
|
|
283
|
+
.mcpm-fold { flex: none; color: var(--dsw-alias-label-tertiary); transform: rotate(-90deg); transition: transform 160ms ease; }
|
|
284
|
+
.mcpm-fold.is-open { transform: rotate(0deg); }
|
|
285
|
+
.mcpm-source-label { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
|
286
|
+
.mcpm-source-brand { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px;
|
|
287
|
+
flex-shrink: 0; color: var(--dsw-alias-label-secondary); }
|
|
288
|
+
.mcpm-source-brand svg { display: block; width: 16px; height: 16px; }
|
|
289
|
+
.mcpm-source-title { font-size: 14px; font-weight: 650; white-space: nowrap; flex: none; }
|
|
290
|
+
.mcpm-count { flex: none; padding: 1px 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2);
|
|
291
|
+
font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
292
|
+
.mcpm-source-path { min-width: 0; max-width: 50%; margin-left: auto; overflow: hidden; text-overflow: ellipsis;
|
|
293
|
+
white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
294
|
+
.mcpm-source-actions { display: flex; flex: none; align-items: center; gap: 8px; margin-left: 12px; }
|
|
295
|
+
@container (max-width: 560px) { .mcpm-source-path { display: none; } }
|
|
296
|
+
|
|
297
|
+
.mcpm-row { display: flex; align-items: center; gap: 8px; margin: 0; padding: 11px 13px;
|
|
298
|
+
border-top: 0.5px solid var(--dsw-alias-border-l1); }
|
|
299
|
+
.mcpm-row-copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1;
|
|
300
|
+
padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
301
|
+
.mcpm-row-copy:focus-visible { outline: var(--dsw-focus-ring-width, 1px) solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: var(--dsw-radius-sm, 6px); }
|
|
302
|
+
.mcpm-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
303
|
+
font-size: 14px; font-weight: 650; color: var(--dsw-alias-label-primary); }
|
|
304
|
+
.mcpm-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
305
|
+
color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
|
306
|
+
.mcpm-row-actions { display: flex; align-items: center; gap: 8px; flex: none; flex-wrap: nowrap; }
|
|
307
|
+
|
|
308
|
+
.mcpm-switch { box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px;
|
|
309
|
+
border: 0; border-radius: 999px; background: var(--dsw-alias-border-l3, #555); cursor: pointer; }
|
|
310
|
+
.mcpm-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
|
|
311
|
+
.mcpm-switch:disabled { cursor: default; opacity: .5; }
|
|
312
|
+
.mcpm-switch:focus-visible { outline: var(--dsw-focus-ring-width, 1px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
313
|
+
.mcpm-switch-thumb { display: block; width: 16px; height: 16px; border-radius: 50%;
|
|
314
|
+
background: var(--dsw-alias-switch-thumb, #fff); transition: transform 120ms ease; pointer-events: none; }
|
|
315
|
+
.mcpm-switch[aria-checked="true"] .mcpm-switch-thumb { transform: translateX(16px); }
|
|
316
|
+
|
|
317
|
+
.mcpm-tag { flex: none; font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
|
|
318
|
+
.mcpm-tag.ok { color: var(--dsw-alias-state-success-primary); }
|
|
319
|
+
.mcpm-tag.warn { color: var(--dsw-alias-state-warn-primary); }
|
|
320
|
+
.mcpm-tag.err { color: var(--dsw-alias-state-error-primary); }
|
|
321
|
+
.mcpm-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
|
|
322
|
+
.mcpm-dot.ok { background: var(--dsw-alias-state-success-primary); }
|
|
323
|
+
.mcpm-dot.err { background: var(--dsw-alias-state-error-primary); }
|
|
324
|
+
|
|
325
|
+
.mcpm-empty { padding: 25px 14px; color: var(--dsw-alias-label-tertiary); font-size: 12px; text-align: center; }
|
|
326
|
+
.mcpm-banner { border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 16px);
|
|
327
|
+
padding: 9px 11px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
|
|
328
|
+
.mcpm-banner.warn { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); }
|
|
329
|
+
.mcpm-error { border: 0.5px solid var(--dsw-alias-state-error-primary); border-radius: var(--dsw-radius-lg, 16px);
|
|
330
|
+
padding: 9px 11px; color: var(--dsw-alias-state-error-primary); font-size: 12px; line-height: 18px; }
|
|
331
|
+
.mcpm-error-lines { display: flex; flex-direction: column; gap: 6px; }
|
|
332
|
+
.mcpm-toast { position: sticky; bottom: 8px; align-self: center; z-index: 5; padding: 6px 14px;
|
|
333
|
+
border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 999px; background: var(--dsw-alias-bg-overlay);
|
|
334
|
+
color: var(--dsw-alias-label-primary); font-size: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, .18); }
|
|
335
|
+
.mcpm-muted { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
|
|
336
|
+
.mcpm-spin { display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
337
|
+
border: 1.5px solid var(--dsw-alias-border-l2); border-top-color: var(--dsw-alias-brand-primary);
|
|
338
|
+
animation: mcpm-spin .7s linear infinite; vertical-align: -1px; }
|
|
339
|
+
@keyframes mcpm-spin { to { transform: rotate(360deg); } }
|
|
340
|
+
|
|
341
|
+
.mcpm-overlay { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
|
|
342
|
+
padding: 24px; background: rgba(0, 0, 0, .42); }
|
|
343
|
+
.mcpm-modal { box-sizing: border-box; display: flex; flex-direction: column; width: 100%; max-width: 720px;
|
|
344
|
+
max-height: min(80vh, 720px); overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l2);
|
|
345
|
+
border-radius: var(--dsw-radius-xl, 20px); background: var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1));
|
|
346
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, .32); }
|
|
347
|
+
.mcpm-modal-head { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px;
|
|
348
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
|
|
349
|
+
.mcpm-modal-title { min-width: 0; }
|
|
350
|
+
.mcpm-modal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; flex: none; margin-left: auto; }
|
|
351
|
+
.mcpm-modal-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
|
352
|
+
display: flex; flex-direction: column; gap: 16px; padding: 14px 16px; }
|
|
353
|
+
|
|
354
|
+
.mcpm-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
|
|
355
|
+
.mcpm-group-title { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
356
|
+
.mcpm-kv { display: grid; grid-template-columns: minmax(76px, max-content) 1fr; gap: 3px 14px; margin: 0;
|
|
357
|
+
font-size: 12px; line-height: 18px; }
|
|
358
|
+
.mcpm-kv dt { color: var(--dsw-alias-label-secondary); }
|
|
359
|
+
.mcpm-kv dd { margin: 0; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); font-size: 12px; }
|
|
360
|
+
.mcpm-pre { margin: 0; max-height: 220px; overflow: auto; padding: 10px 12px; border-radius: var(--dsw-radius-md, 12px);
|
|
361
|
+
border: 0.5px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base);
|
|
362
|
+
font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11.5px; line-height: 1.55; white-space: pre; }
|
|
363
|
+
.mcpm-check { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 12.5px; cursor: pointer; }
|
|
364
|
+
.mcpm-check input { accent-color: var(--dsw-alias-brand-primary); }
|
|
365
|
+
.mcpm-target { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 5px 0; border-top: 0.5px solid var(--dsw-alias-border-l1); font-size: 12px; }
|
|
366
|
+
.mcpm-target:first-child { border-top: 0; }
|
|
367
|
+
.mcpm-target-name { flex: none; color: var(--dsw-alias-label-primary); }
|
|
368
|
+
.mcpm-target-path { min-width: 0; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
369
|
+
color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
370
|
+
@media (prefers-reduced-motion: reduce) {
|
|
371
|
+
.mcpm-fold, .mcpm-switch-thumb { transition: none; }
|
|
372
|
+
.mcpm-spin { animation: none; }
|
|
373
|
+
}
|
|
374
|
+
`;
|
|
375
|
+
|
|
376
|
+
/* ----------------------------------------------------------- utilities -- */
|
|
377
|
+
|
|
378
|
+
function transportLabel(transport) {
|
|
379
|
+
if (transport === 'stdio') return 'stdio';
|
|
380
|
+
if (transport === 'http-sse') return 'SSE';
|
|
381
|
+
return 'HTTP';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function scopeLabel(t, scope) {
|
|
385
|
+
if (scope === 'project') return t('label.project');
|
|
386
|
+
if (scope === 'profile') return t('label.profile');
|
|
387
|
+
return t('label.global');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Shorten an absolute path for display only.
|
|
392
|
+
*
|
|
393
|
+
* The scanned path is the real one and stays in the payload (the detail view
|
|
394
|
+
* and the tooltips show it in full); the card head only has room for a hint,
|
|
395
|
+
* and `%APPDATA%\Qoder\SharedClientCache\mcp.json` answers "which directory"
|
|
396
|
+
* in a third of the width of the absolute form. This is a pure string
|
|
397
|
+
* transform — nothing is ever written back in this form.
|
|
398
|
+
*
|
|
399
|
+
* Both path flavours are handled because the adapter layer is cross-platform:
|
|
400
|
+
* a macOS Host produces `/Users/me/Library/Application Support/...`, and
|
|
401
|
+
* collapsing it to `~/Library/...` keeps the head uniform across platforms.
|
|
402
|
+
*/
|
|
403
|
+
function shortenPath(path) {
|
|
404
|
+
const text = String(path ?? '');
|
|
405
|
+
if (!text || text.startsWith('(')) return text;
|
|
406
|
+
return text
|
|
407
|
+
.replace(/^[A-Za-z]:\\Users\\[^\\]+\\AppData\\Roaming\\?/i, '%APPDATA%\\')
|
|
408
|
+
.replace(/^[A-Za-z]:\\Users\\[^\\]+\\AppData\\Local\\?/i, '%LOCALAPPDATA%\\')
|
|
409
|
+
.replace(/^[A-Za-z]:\\Users\\[^\\]+\\?/i, '~\\')
|
|
410
|
+
.replace(/^\/(?:Users|home)\/[^/]+\/?/, '~/');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* One-line descriptor for a row: transport plus the command or URL.
|
|
415
|
+
*
|
|
416
|
+
* The full value is kept on `title` and shown in the detail view; the row
|
|
417
|
+
* itself must never wrap, because a list where rows have different heights
|
|
418
|
+
* is the difference between a table and a pile.
|
|
419
|
+
*/
|
|
420
|
+
function metaLine(item) {
|
|
421
|
+
const server = item.server ?? {};
|
|
422
|
+
const transport = transportLabel(item.transport);
|
|
423
|
+
const detail = item.transport === 'stdio'
|
|
424
|
+
? [server.command, ...(server.args ?? [])].filter(Boolean).join(' ')
|
|
425
|
+
: String(server.url ?? '');
|
|
426
|
+
return detail ? `${transport} · ${detail}` : transport;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function maskText(value) {
|
|
430
|
+
const text = String(value ?? '');
|
|
431
|
+
if (text.length <= 8) return '••••';
|
|
432
|
+
return `${text.slice(0, 4)}••••${text.slice(-4)}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function looksSecret(key, value) {
|
|
436
|
+
if (typeof value !== 'string') return false;
|
|
437
|
+
if (!/(token|key|secret|password|passwd|credential|auth|cookie|session|private)/i.test(key)) return false;
|
|
438
|
+
if (value.length < 12) return false;
|
|
439
|
+
if (/^\$\{|\{\{|^%[A-Z_]+%$|^<.*>$|^\{env:/.test(value)) return false;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function CopyButton({ t, text }) {
|
|
444
|
+
const [done, setDone] = useState(false);
|
|
445
|
+
return h('button', {
|
|
446
|
+
className: 'mcpm-btn sm',
|
|
447
|
+
onClick: async (event) => {
|
|
448
|
+
event.stopPropagation();
|
|
449
|
+
try {
|
|
450
|
+
await navigator.clipboard.writeText(text);
|
|
451
|
+
setDone(true);
|
|
452
|
+
setTimeout(() => setDone(false), 1600);
|
|
453
|
+
} catch {
|
|
454
|
+
setDone(false);
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
}, done ? t('btn.copied') : t('btn.copy'));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function DefinitionList({ server, reveal }) {
|
|
461
|
+
const rows = [];
|
|
462
|
+
rows.push(['transport', transportLabel(server.transport)]);
|
|
463
|
+
if (server.transport === 'stdio') {
|
|
464
|
+
rows.push(['command', [server.command, ...(server.args ?? [])].filter(Boolean).join(' ')]);
|
|
465
|
+
if (server.cwd) rows.push(['cwd', server.cwd]);
|
|
466
|
+
} else if (server.url) {
|
|
467
|
+
rows.push(['url', server.url]);
|
|
468
|
+
}
|
|
469
|
+
rows.push(['enabled', String(server.enabled !== false)]);
|
|
470
|
+
if (typeof server.timeoutMs === 'number') rows.push(['timeoutMs', String(server.timeoutMs)]);
|
|
471
|
+
for (const [key, value] of Object.entries(server.env ?? {})) {
|
|
472
|
+
const masked = !reveal && looksSecret(key, value) ? maskText(value) : String(value);
|
|
473
|
+
rows.push([`env.${key}`, masked]);
|
|
474
|
+
}
|
|
475
|
+
for (const [key, value] of Object.entries(server.headers ?? {})) {
|
|
476
|
+
const masked = !reveal && looksSecret(key, value) ? maskText(value) : String(value);
|
|
477
|
+
rows.push([`header.${key}`, masked]);
|
|
478
|
+
}
|
|
479
|
+
return h('dl', { className: 'mcpm-kv' }, rows.flatMap(([key, value]) => [
|
|
480
|
+
h('dt', { key: `${key}-k` }, key),
|
|
481
|
+
h('dd', { key: `${key}-v` }, value),
|
|
482
|
+
]));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* --------------------------------------------------------------- page -- */
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The mount switch.
|
|
489
|
+
*
|
|
490
|
+
* It renders `role="switch"` rather than a styled checkbox so the state is
|
|
491
|
+
* announced to assistive technology and the thumb animation has a state to
|
|
492
|
+
* key on — the same contract the skills page uses for its own switch.
|
|
493
|
+
*/
|
|
494
|
+
function MountSwitch({ checked, disabled, onToggle, label }) {
|
|
495
|
+
return h('button', {
|
|
496
|
+
type: 'button',
|
|
497
|
+
role: 'switch',
|
|
498
|
+
className: 'mcpm-switch',
|
|
499
|
+
'aria-checked': checked ? 'true' : 'false',
|
|
500
|
+
'aria-label': label,
|
|
501
|
+
title: label,
|
|
502
|
+
disabled,
|
|
503
|
+
onClick: (event) => {
|
|
504
|
+
event.stopPropagation();
|
|
505
|
+
onToggle(!checked);
|
|
506
|
+
},
|
|
507
|
+
}, h('span', { className: 'mcpm-switch-thumb' }));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const FILE_ICON = h('svg', {
|
|
511
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor',
|
|
512
|
+
strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
|
|
513
|
+
}, [
|
|
514
|
+
h('path', { key: 'a', d: 'M3.5 1.5h5L12.5 5.5v9a.5.5 0 0 1-.5.5H3.5a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5Z' }),
|
|
515
|
+
h('path', { key: 'b', d: 'M8.5 1.5v4h4' }),
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const FOLD_ICON = h('svg', {
|
|
519
|
+
width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none', stroke: 'currentColor',
|
|
520
|
+
strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
|
|
521
|
+
}, h('path', { d: 'M3 4.5 6 7.5l3-3' }));
|
|
522
|
+
|
|
523
|
+
function McpManagerPage(props) {
|
|
524
|
+
const ctx = props.__ctx;
|
|
525
|
+
const t = makeT(() => ctx.locale.getLocale().active);
|
|
526
|
+
|
|
527
|
+
const [state, setState] = useState(null);
|
|
528
|
+
const [error, setError] = useState(null);
|
|
529
|
+
const [loading, setLoading] = useState(true);
|
|
530
|
+
const [reveal, setReveal] = useState(false);
|
|
531
|
+
const [query, setQuery] = useState('');
|
|
532
|
+
const [toolFilter, setToolFilter] = useState('all');
|
|
533
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
534
|
+
const [openName, setOpenName] = useState(null);
|
|
535
|
+
/*
|
|
536
|
+
* Cards start COLLAPSED. With a dozen files and dozens of servers the page
|
|
537
|
+
* opened as a wall of rows, and the thing the user actually navigates by is
|
|
538
|
+
* the file list — the rows are the detail behind it. So the default is
|
|
539
|
+
* closed, and only what the user opened (or what a filter matched) shows.
|
|
540
|
+
*/
|
|
541
|
+
const [expanded, setExpanded] = useState({});
|
|
542
|
+
const [probes, setProbes] = useState({});
|
|
543
|
+
const [probing, setProbing] = useState({});
|
|
544
|
+
const [busy, setBusy] = useState(false);
|
|
545
|
+
const [toast, setToast] = useState(null);
|
|
546
|
+
const [snippet, setSnippet] = useState(null);
|
|
547
|
+
const [syncOpen, setSyncOpen] = useState(false);
|
|
548
|
+
const [syncPicks, setSyncPicks] = useState([]);
|
|
549
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
550
|
+
const mounted = useRef(true);
|
|
551
|
+
|
|
552
|
+
useEffect(() => () => { mounted.current = false; }, []);
|
|
553
|
+
|
|
554
|
+
const flash = useCallback((text) => {
|
|
555
|
+
setToast(text);
|
|
556
|
+
setTimeout(() => { if (mounted.current) setToast(null); }, 2800);
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
const load = useCallback(async (nextReveal) => {
|
|
560
|
+
setLoading(true);
|
|
561
|
+
setError(null);
|
|
562
|
+
try {
|
|
563
|
+
const data = await fetchState(nextReveal ?? reveal);
|
|
564
|
+
if (!mounted.current) return;
|
|
565
|
+
setState(data);
|
|
566
|
+
} catch (cause) {
|
|
567
|
+
if (mounted.current) setError(cause);
|
|
568
|
+
} finally {
|
|
569
|
+
if (mounted.current) setLoading(false);
|
|
570
|
+
}
|
|
571
|
+
}, [reveal]);
|
|
572
|
+
|
|
573
|
+
useEffect(() => { load(reveal); }, [reveal]);
|
|
574
|
+
|
|
575
|
+
/* Escape closes the detail view, the way every other dialog here behaves. */
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
if (!openName) return undefined;
|
|
578
|
+
const onKey = (event) => { if (event.key === 'Escape') setOpenName(null); };
|
|
579
|
+
window.addEventListener('keydown', onKey);
|
|
580
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
581
|
+
}, [openName]);
|
|
582
|
+
|
|
583
|
+
const installedClients = useMemo(() => {
|
|
584
|
+
if (!state) return [];
|
|
585
|
+
return state.clients
|
|
586
|
+
.filter((client) => client.installed)
|
|
587
|
+
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
|
588
|
+
}, [state]);
|
|
589
|
+
|
|
590
|
+
/*
|
|
591
|
+
* Mount state is a property of the SERVER NAME, not of the file it came
|
|
592
|
+
* from: one name can be declared in five tools while being mounted in
|
|
593
|
+
* DeepSeek Harness exactly once. Indexing it here keeps every row's
|
|
594
|
+
* switch consistent with the DSH card.
|
|
595
|
+
*/
|
|
596
|
+
const mountIndex = useMemo(() => {
|
|
597
|
+
const index = new Map();
|
|
598
|
+
if (!state) return index;
|
|
599
|
+
for (const server of state.servers) {
|
|
600
|
+
const dsh = server.targets.find((target) => target.clientId === 'dsh');
|
|
601
|
+
index.set(server.name, {
|
|
602
|
+
mounted: Boolean(dsh),
|
|
603
|
+
enabled: dsh ? dsh.enabled !== false : false,
|
|
604
|
+
pending: dsh?.pending === true,
|
|
605
|
+
entryId: dsh?.entryId ?? null,
|
|
606
|
+
node: server,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
return index;
|
|
610
|
+
}, [state]);
|
|
611
|
+
|
|
612
|
+
const sources = useMemo(() => {
|
|
613
|
+
if (!state) return [];
|
|
614
|
+
const order = new Map(installedClients.map((client, position) => [client.id, position]));
|
|
615
|
+
return [...(state.sources ?? [])]
|
|
616
|
+
.filter((source) => source.exists)
|
|
617
|
+
.sort((left, right) => (order.get(left.clientId) ?? 99) - (order.get(right.clientId) ?? 99)
|
|
618
|
+
|| String(left.path).localeCompare(String(right.path)));
|
|
619
|
+
}, [state, installedClients]);
|
|
620
|
+
|
|
621
|
+
/*
|
|
622
|
+
* Filtering happens on rows, not on cards: hiding a whole file because one
|
|
623
|
+
* of its servers matched would hide the other matches. A file survives only
|
|
624
|
+
* when at least one of its rows does, and an unfiltered view keeps the empty
|
|
625
|
+
* files so the user can still see that a tool is installed but idle.
|
|
626
|
+
*/
|
|
627
|
+
const filtering = query.trim() !== '' || toolFilter !== 'all' || statusFilter !== 'all';
|
|
628
|
+
|
|
629
|
+
const visible = useMemo(() => {
|
|
630
|
+
const needle = query.trim().toLowerCase();
|
|
631
|
+
const out = [];
|
|
632
|
+
for (const source of sources) {
|
|
633
|
+
if (toolFilter !== 'all' && source.clientId !== toolFilter) continue;
|
|
634
|
+
const rows = [];
|
|
635
|
+
for (const item of source.servers) {
|
|
636
|
+
const mount = mountIndex.get(item.name);
|
|
637
|
+
const isMounted = Boolean(mount?.mounted && mount.enabled);
|
|
638
|
+
if (statusFilter === 'mounted' && !isMounted) continue;
|
|
639
|
+
if (statusFilter === 'unmounted' && isMounted) continue;
|
|
640
|
+
if (needle) {
|
|
641
|
+
const haystack = [
|
|
642
|
+
item.name,
|
|
643
|
+
item.transport,
|
|
644
|
+
source.clientLabel?.en ?? '',
|
|
645
|
+
source.path,
|
|
646
|
+
item.server?.command ?? '',
|
|
647
|
+
(item.server?.args ?? []).join(' '),
|
|
648
|
+
item.server?.url ?? '',
|
|
649
|
+
].join(' ').toLowerCase();
|
|
650
|
+
if (!haystack.includes(needle)) continue;
|
|
651
|
+
}
|
|
652
|
+
rows.push(item);
|
|
653
|
+
}
|
|
654
|
+
if (filtering && rows.length === 0) continue;
|
|
655
|
+
out.push({ source, rows: rows.sort((left, right) => left.name.localeCompare(right.name)) });
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}, [sources, query, toolFilter, statusFilter, mountIndex, filtering]);
|
|
659
|
+
|
|
660
|
+
const openServer = useMemo(
|
|
661
|
+
() => (state && openName ? state.servers.find((server) => server.name === openName) : null),
|
|
662
|
+
[state, openName],
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
const mountedCount = useMemo(
|
|
666
|
+
() => [...mountIndex.values()].filter((item) => item.mounted && item.enabled).length,
|
|
667
|
+
[mountIndex],
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const run = async (action, body, success, after) => {
|
|
671
|
+
setBusy(true);
|
|
672
|
+
setError(null);
|
|
673
|
+
try {
|
|
674
|
+
const data = await api(action, body);
|
|
675
|
+
if (data?.state) {
|
|
676
|
+
/* The toggle answers with the fresh snapshot; reuse it instead of a second round trip. */
|
|
677
|
+
if (mounted.current) setState(data.state);
|
|
678
|
+
} else {
|
|
679
|
+
await load(reveal);
|
|
680
|
+
}
|
|
681
|
+
if (success) flash(success);
|
|
682
|
+
if (after) after(data);
|
|
683
|
+
return data;
|
|
684
|
+
} catch (cause) {
|
|
685
|
+
setError(cause);
|
|
686
|
+
return null;
|
|
687
|
+
} finally {
|
|
688
|
+
setBusy(false);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const probeOne = async (name) => {
|
|
693
|
+
setProbing((current) => ({ ...current, [name]: true }));
|
|
694
|
+
try {
|
|
695
|
+
const data = await api('probe', { names: [name] });
|
|
696
|
+
setProbes((current) => ({ ...current, ...(data?.results ?? {}) }));
|
|
697
|
+
} catch (cause) {
|
|
698
|
+
setError(cause);
|
|
699
|
+
} finally {
|
|
700
|
+
setProbing((current) => ({ ...current, [name]: false }));
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const probeAll = async () => {
|
|
705
|
+
const names = state ? state.servers.map((server) => server.name) : [];
|
|
706
|
+
setProbing(Object.fromEntries(names.map((name) => [name, true])));
|
|
707
|
+
try {
|
|
708
|
+
const data = await api('probe', {});
|
|
709
|
+
setProbes((current) => ({ ...current, ...(data?.results ?? {}) }));
|
|
710
|
+
} catch (cause) {
|
|
711
|
+
setError(cause);
|
|
712
|
+
} finally {
|
|
713
|
+
setProbing({});
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/*
|
|
718
|
+
* One switch, one meaning: "mounted in DeepSeek Harness".
|
|
719
|
+
*
|
|
720
|
+
* Turning it on mounts the server there (writing our own profile patch when
|
|
721
|
+
* no Loader row exists yet). Turning it off unmounts it. The file the
|
|
722
|
+
* definition came from is never opened for writing.
|
|
723
|
+
*
|
|
724
|
+
* A freshly written patch row is reconciled by the Loader a moment later,
|
|
725
|
+
* so `activating` is not an error and certainly not a restart demand: the
|
|
726
|
+
* page says it is coming up and re-reads the state shortly after.
|
|
727
|
+
*/
|
|
728
|
+
const toggleMount = async (name, next) => {
|
|
729
|
+
const result = await run('toggle', { name, enabled: next }, null, (data) => {
|
|
730
|
+
if (data?.restartRequired) flash(t('toast.mountPending', { name }));
|
|
731
|
+
else if (next && data?.activating) {
|
|
732
|
+
flash(t('toast.mounting', { name }));
|
|
733
|
+
setTimeout(() => { void load(reveal); }, 2500);
|
|
734
|
+
} else flash(next ? t('toast.mounted', { name }) : t('toast.unmounted', { name }));
|
|
735
|
+
});
|
|
736
|
+
if (!result) flash(t('error.mount'));
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const doSync = async (server) => {
|
|
740
|
+
if (!window.confirm(t('confirm.sync', { name: server.name, count: syncPicks.length }))) return;
|
|
741
|
+
const data = await run('sync', { name: server.name, server: server.canonical, targets: syncPicks });
|
|
742
|
+
if (data) {
|
|
743
|
+
setSyncOpen(false);
|
|
744
|
+
flash(t('toast.copied', { count: (data.results ?? []).filter((item) => item.ok).length }));
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const openSnippet = async (server) => {
|
|
749
|
+
const data = await run('snippet', { name: server.name });
|
|
750
|
+
if (data) setSnippet(data.yaml);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const toggleReveal = async () => {
|
|
754
|
+
const next = !reveal;
|
|
755
|
+
if (next && !window.confirm(t('confirm.reveal'))) return;
|
|
756
|
+
setReveal(next);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const closeDetail = () => {
|
|
760
|
+
setOpenName(null);
|
|
761
|
+
setSnippet(null);
|
|
762
|
+
setSyncOpen(false);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
/* ------------------------------------------------------------ render -- */
|
|
766
|
+
|
|
767
|
+
const header = h('div', { className: 'mcpm-head' }, [
|
|
768
|
+
h('div', { className: 'mcpm-title-row', key: 'title' }, [
|
|
769
|
+
h('div', { className: 'mcpm-title-block', key: 'block' }, h('h1', { className: 'mcpm-h1' }, t('title'))),
|
|
770
|
+
h('div', { className: 'mcpm-actions', key: 'actions' }, [
|
|
771
|
+
h('button', {
|
|
772
|
+
key: 'probe',
|
|
773
|
+
className: 'mcpm-btn',
|
|
774
|
+
disabled: busy || loading || !state || state.servers.length === 0,
|
|
775
|
+
onClick: probeAll,
|
|
776
|
+
}, t('btn.probeAll')),
|
|
777
|
+
h('button', {
|
|
778
|
+
key: 'reveal',
|
|
779
|
+
className: 'mcpm-btn',
|
|
780
|
+
disabled: loading || !state,
|
|
781
|
+
onClick: toggleReveal,
|
|
782
|
+
}, reveal ? t('btn.hide') : t('btn.reveal')),
|
|
783
|
+
h('button', {
|
|
784
|
+
key: 'refresh',
|
|
785
|
+
className: 'mcpm-btn',
|
|
786
|
+
disabled: loading,
|
|
787
|
+
onClick: () => load(reveal),
|
|
788
|
+
}, t('btn.refresh')),
|
|
789
|
+
]),
|
|
790
|
+
]),
|
|
791
|
+
h('p', { className: 'mcpm-desc', key: 'desc' }, t('desc')),
|
|
792
|
+
]);
|
|
793
|
+
|
|
794
|
+
const summary = state ? h('div', { className: 'mcpm-summary', key: 'summary' }, [
|
|
795
|
+
h('div', { className: 'mcpm-stat', key: 'servers' }, [h('strong', { key: 'v' }, String(state.servers.length)), t('summary.servers')]),
|
|
796
|
+
h('div', { className: 'mcpm-stat', key: 'files' }, [h('strong', { key: 'v' }, String(sources.length)), t('summary.files')]),
|
|
797
|
+
h('div', { className: 'mcpm-stat', key: 'mounted' }, [h('strong', { key: 'v' }, String(mountedCount)), t('summary.mounted')]),
|
|
798
|
+
h('div', { className: 'mcpm-stat', key: 'secrets' }, [h('strong', { key: 'v' }, String(state.literalSecrets.length)), t('summary.secrets')]),
|
|
799
|
+
]) : null;
|
|
800
|
+
|
|
801
|
+
const filters = state ? h('div', { className: 'mcpm-filters', key: 'filters' }, [
|
|
802
|
+
h('input', {
|
|
803
|
+
key: 'q',
|
|
804
|
+
type: 'search',
|
|
805
|
+
className: 'mcpm-search',
|
|
806
|
+
placeholder: t('filter.search'),
|
|
807
|
+
'aria-label': t('filter.search'),
|
|
808
|
+
value: query,
|
|
809
|
+
onChange: (event) => setQuery(event.target.value),
|
|
810
|
+
}),
|
|
811
|
+
h('select', {
|
|
812
|
+
key: 'tool',
|
|
813
|
+
className: 'mcpm-select',
|
|
814
|
+
'aria-label': t('filter.toolAll'),
|
|
815
|
+
value: toolFilter,
|
|
816
|
+
onChange: (event) => setToolFilter(event.target.value),
|
|
817
|
+
}, [
|
|
818
|
+
h('option', { key: 'all', value: 'all' }, t('filter.toolAll')),
|
|
819
|
+
...installedClients.map((client) => h('option', {
|
|
820
|
+
key: client.id,
|
|
821
|
+
value: client.id,
|
|
822
|
+
}, client.label?.en ?? client.id)),
|
|
823
|
+
]),
|
|
824
|
+
h('select', {
|
|
825
|
+
key: 'status',
|
|
826
|
+
className: 'mcpm-select',
|
|
827
|
+
'aria-label': t('filter.statusAll'),
|
|
828
|
+
value: statusFilter,
|
|
829
|
+
onChange: (event) => setStatusFilter(event.target.value),
|
|
830
|
+
}, [
|
|
831
|
+
h('option', { key: 'all', value: 'all' }, t('filter.statusAll')),
|
|
832
|
+
h('option', { key: 'mounted', value: 'mounted' }, t('filter.statusMounted')),
|
|
833
|
+
h('option', { key: 'unmounted', value: 'unmounted' }, t('filter.statusUnmounted')),
|
|
834
|
+
]),
|
|
835
|
+
]) : null;
|
|
836
|
+
|
|
837
|
+
const banners = state ? [
|
|
838
|
+
state.literalSecrets.length
|
|
839
|
+
? h('div', { className: 'mcpm-banner warn', key: 'secrets' }, t('banner.secrets', { count: state.literalSecrets.length }))
|
|
840
|
+
: null,
|
|
841
|
+
state.warnings.length
|
|
842
|
+
? h('div', { className: 'mcpm-banner warn', key: 'parse' }, t('banner.parse', { count: state.warnings.length }))
|
|
843
|
+
: null,
|
|
844
|
+
].filter(Boolean) : [];
|
|
845
|
+
|
|
846
|
+
const probeTag = (name) => {
|
|
847
|
+
if (probing[name]) return h('span', { className: 'mcpm-spin', key: 'p' });
|
|
848
|
+
const probe = probes[name];
|
|
849
|
+
if (!probe) return null;
|
|
850
|
+
const ok = probe.status === 'ok';
|
|
851
|
+
return h('span', { className: 'mcpm-tag' + (ok ? ' ok' : ' err'), key: 'p', title: ok
|
|
852
|
+
? t('probe.ok', { tools: probe.toolCount, ms: probe.elapsedMs })
|
|
853
|
+
: (probe.detail ?? t('probe.failed')) }, [
|
|
854
|
+
h('span', { className: 'mcpm-dot' + (ok ? ' ok' : ' err'), key: 'd' }),
|
|
855
|
+
' ',
|
|
856
|
+
ok ? t('probe.tools', { count: probe.toolCount }) : t('probe.failed'),
|
|
857
|
+
]);
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const rowFor = (source, item) => {
|
|
861
|
+
const mount = mountIndex.get(item.name);
|
|
862
|
+
const node = mount?.node ?? null;
|
|
863
|
+
const checked = Boolean(mount?.mounted && mount.enabled);
|
|
864
|
+
const shared = node ? node.clientCount : 1;
|
|
865
|
+
const isDsh = source.clientId === 'dsh';
|
|
866
|
+
|
|
867
|
+
const copy = h('button', {
|
|
868
|
+
key: 'copy',
|
|
869
|
+
type: 'button',
|
|
870
|
+
className: 'mcpm-row-copy',
|
|
871
|
+
onClick: () => setOpenName(item.name),
|
|
872
|
+
}, [
|
|
873
|
+
h('div', { className: 'mcpm-name', key: 'n', title: item.name }, item.name),
|
|
874
|
+
h('div', { className: 'mcpm-meta', key: 'd', title: metaLine(item) }, metaLine(item)),
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
return h('div', { className: 'mcpm-row', key: item.name }, [
|
|
878
|
+
copy,
|
|
879
|
+
h('div', { className: 'mcpm-row-actions', key: 'a' }, [
|
|
880
|
+
shared > 1 ? h('span', { className: 'mcpm-tag', key: 'sh', title: node?.clients?.join(', ') ?? '' }, t('label.shared', { count: shared })) : null,
|
|
881
|
+
mount?.pending ? h('span', { className: 'mcpm-tag warn', key: 'pd' }, t('label.pending')) : null,
|
|
882
|
+
isDsh && !checked ? h('span', { className: 'mcpm-tag', key: 'off' }, t('label.off')) : null,
|
|
883
|
+
probeTag(item.name),
|
|
884
|
+
h(MountSwitch, {
|
|
885
|
+
key: 'sw',
|
|
886
|
+
checked,
|
|
887
|
+
/*
|
|
888
|
+
* Any in-flight write locks every switch, not just its own row: two
|
|
889
|
+
* concurrent toggles would both read the composition and the second
|
|
890
|
+
* would write a file the first one already replaced.
|
|
891
|
+
*/
|
|
892
|
+
disabled: busy,
|
|
893
|
+
label: t('switch.title', { client: source.clientLabel?.en ?? source.clientId }),
|
|
894
|
+
onToggle: (next) => toggleMount(item.name, next),
|
|
895
|
+
}),
|
|
896
|
+
]),
|
|
897
|
+
]);
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const sourceCard = (entry) => {
|
|
901
|
+
const { source } = entry;
|
|
902
|
+
/* While filtering, every card opens: a match hidden inside a closed card
|
|
903
|
+
reads as "no results" and the user would keep typing. */
|
|
904
|
+
const isOpen = filtering || expanded[source.id] === true;
|
|
905
|
+
const rows = entry.rows;
|
|
906
|
+
const label = source.clientLabel?.en ?? source.clientId;
|
|
907
|
+
return h('section', { className: 'mcpm-source', key: source.id }, [
|
|
908
|
+
h('div', { className: 'mcpm-source-head', key: 'head' }, [
|
|
909
|
+
h('button', {
|
|
910
|
+
key: 'toggle',
|
|
911
|
+
type: 'button',
|
|
912
|
+
className: 'mcpm-source-toggle',
|
|
913
|
+
'aria-expanded': isOpen ? 'true' : 'false',
|
|
914
|
+
onClick: () => setExpanded((current) => ({ ...current, [source.id]: !isOpen })),
|
|
915
|
+
}, [
|
|
916
|
+
h('span', { className: 'mcpm-fold' + (isOpen ? ' is-open' : ''), key: 'f' }, FOLD_ICON),
|
|
917
|
+
h('span', { className: 'mcpm-source-label', key: 'l' }, [
|
|
918
|
+
h('span', { className: 'mcpm-source-brand', key: 'b', title: source.format }, FILE_ICON),
|
|
919
|
+
h('span', { className: 'mcpm-source-title', key: 't' }, label),
|
|
920
|
+
h('span', { className: 'mcpm-count', key: 'n' }, String(source.serverCount)),
|
|
921
|
+
source.readOnly ? h('span', { className: 'mcpm-tag', key: 'ro' }, t('label.readOnly')) : null,
|
|
922
|
+
h('span', { className: 'mcpm-source-path', key: 'p', title: source.path }, shortenPath(source.path)),
|
|
923
|
+
]),
|
|
924
|
+
]),
|
|
925
|
+
h('span', { className: 'mcpm-source-actions', key: 'actions' },
|
|
926
|
+
h('span', { className: 'mcpm-tag' }, scopeLabel(t, source.scope))),
|
|
927
|
+
]),
|
|
928
|
+
isOpen ? h('div', { key: 'body' }, rows.length === 0
|
|
929
|
+
? h('div', { className: 'mcpm-empty', key: 'e' }, t('detail.emptyFile'))
|
|
930
|
+
: rows.map((item) => rowFor(source, item))) : null,
|
|
931
|
+
]);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const historyCard = historyOpen && state ? h('section', { className: 'mcpm-source', key: 'history' }, [
|
|
935
|
+
h('div', { className: 'mcpm-source-head', key: 'head' }, h('div', { className: 'mcpm-source-label' }, [
|
|
936
|
+
h('span', { className: 'mcpm-source-brand', key: 'b' }, FILE_ICON),
|
|
937
|
+
h('span', { className: 'mcpm-source-title', key: 't' }, t('detail.history')),
|
|
938
|
+
h('span', { className: 'mcpm-count', key: 'n' }, String((state.history ?? []).length)),
|
|
939
|
+
])),
|
|
940
|
+
(state.history ?? []).length === 0
|
|
941
|
+
? h('div', { className: 'mcpm-empty', key: 'e' }, t('detail.noHistory'))
|
|
942
|
+
: (state.history ?? []).slice(0, 12).map((item, index) => h('div', { className: 'mcpm-row', key: index }, [
|
|
943
|
+
h('div', { className: 'mcpm-row-copy', key: 'c', style: { cursor: 'default' } }, [
|
|
944
|
+
h('div', { className: 'mcpm-name', key: 'n' }, `${item.label ?? item.action ?? 'write'} · ${item.serverName ?? ''}`),
|
|
945
|
+
h('div', { className: 'mcpm-meta', key: 'm', title: item.path }, `${new Date(item.at).toLocaleString()} · ${shortenPath(item.path)}`),
|
|
946
|
+
]),
|
|
947
|
+
item.backup
|
|
948
|
+
? h('div', { className: 'mcpm-row-actions', key: 'a' }, h('button', {
|
|
949
|
+
key: 'r', className: 'mcpm-btn sm',
|
|
950
|
+
disabled: busy,
|
|
951
|
+
onClick: async () => { await run('restore', { backup: item.backup, path: item.path }, t('toast.restored')); },
|
|
952
|
+
}, t('btn.restore')))
|
|
953
|
+
: null,
|
|
954
|
+
])),
|
|
955
|
+
]) : null;
|
|
956
|
+
|
|
957
|
+
/*
|
|
958
|
+
* The detail view is a dialog, not an expanding row.
|
|
959
|
+
*
|
|
960
|
+
* Inline expansion reflows every row below it and — because the body is
|
|
961
|
+
* taller than a row — makes the card look like two cards stacked. The skills
|
|
962
|
+
* page answers this the same way: a modal over the list, dismissed with
|
|
963
|
+
* Escape or a click outside.
|
|
964
|
+
*/
|
|
965
|
+
const detailModal = openServer ? (() => {
|
|
966
|
+
const server = openServer;
|
|
967
|
+
const mount = mountIndex.get(server.name);
|
|
968
|
+
const otherTargets = installedClients.filter((client) => !client.readOnly
|
|
969
|
+
&& client.id !== 'dsh'
|
|
970
|
+
&& !server.targets.some((target) => target.clientId === client.id));
|
|
971
|
+
/*
|
|
972
|
+
* Targets normally carry their own label, but the label is presentation
|
|
973
|
+
* data the Host may omit (a target written before a client was renamed,
|
|
974
|
+
* or a hand-built payload). Fall back to the catalog row so the dialog
|
|
975
|
+
* never shows a raw id where every other view shows a tool name.
|
|
976
|
+
*/
|
|
977
|
+
const clientLabel = (target) => target.clientLabel?.en
|
|
978
|
+
?? state.clients.find((client) => client.id === target.clientId)?.label?.en
|
|
979
|
+
?? target.clientId;
|
|
980
|
+
return h('div', {
|
|
981
|
+
className: 'mcpm-overlay',
|
|
982
|
+
key: 'detail',
|
|
983
|
+
role: 'presentation',
|
|
984
|
+
onClick: closeDetail,
|
|
985
|
+
}, h('div', {
|
|
986
|
+
className: 'mcpm-modal',
|
|
987
|
+
role: 'dialog',
|
|
988
|
+
'aria-modal': 'true',
|
|
989
|
+
'aria-label': server.name,
|
|
990
|
+
onClick: (event) => event.stopPropagation(),
|
|
991
|
+
}, [
|
|
992
|
+
h('div', { className: 'mcpm-modal-head', key: 'head' }, [
|
|
993
|
+
h('div', { className: 'mcpm-modal-title', key: 'l' }, [
|
|
994
|
+
h('div', { className: 'mcpm-name', key: 'n' }, server.name),
|
|
995
|
+
h('div', { className: 'mcpm-meta', key: 'm' }, [
|
|
996
|
+
transportLabel(server.transport),
|
|
997
|
+
mount?.mounted ? ` · ${mount.enabled ? t('label.mounted') : t('label.off')}` : '',
|
|
998
|
+
].join('')),
|
|
999
|
+
]),
|
|
1000
|
+
h('div', { className: 'mcpm-modal-actions', key: 'r' }, [
|
|
1001
|
+
h('button', {
|
|
1002
|
+
key: 'p', className: 'mcpm-btn sm', disabled: busy,
|
|
1003
|
+
onClick: () => probeOne(server.name),
|
|
1004
|
+
}, t('btn.probe')),
|
|
1005
|
+
h('button', {
|
|
1006
|
+
key: 'y', className: 'mcpm-btn sm', disabled: busy,
|
|
1007
|
+
onClick: () => openSnippet(server),
|
|
1008
|
+
}, t('btn.snippet')),
|
|
1009
|
+
h('button', {
|
|
1010
|
+
key: 's', className: 'mcpm-btn sm', disabled: busy || otherTargets.length === 0,
|
|
1011
|
+
onClick: () => { setSyncPicks([]); setSnippet(null); setSyncOpen(!syncOpen); },
|
|
1012
|
+
}, t('btn.sync')),
|
|
1013
|
+
h('button', { key: 'c', className: 'mcpm-btn sm quiet', onClick: closeDetail }, t('btn.close')),
|
|
1014
|
+
]),
|
|
1015
|
+
]),
|
|
1016
|
+
|
|
1017
|
+
h('div', { className: 'mcpm-modal-body', key: 'body' }, [
|
|
1018
|
+
h('div', { className: 'mcpm-group', key: 'targets' }, [
|
|
1019
|
+
h('div', { className: 'mcpm-group-title', key: 't' }, t('detail.targets')),
|
|
1020
|
+
...server.targets.map((target) => h('div', { className: 'mcpm-target', key: `${target.clientId}:${target.path}` }, [
|
|
1021
|
+
h('span', { className: 'mcpm-target-name', key: 'n' }, clientLabel(target)),
|
|
1022
|
+
target.pending ? h('span', { className: 'mcpm-tag warn', key: 'pd' }, t('label.pending')) : null,
|
|
1023
|
+
target.server?.enabled === false ? h('span', { className: 'mcpm-tag', key: 'off' }, t('label.off')) : null,
|
|
1024
|
+
h('span', { className: 'mcpm-target-path', key: 'p', title: target.path }, shortenPath(target.path)),
|
|
1025
|
+
])),
|
|
1026
|
+
]),
|
|
1027
|
+
|
|
1028
|
+
h('div', { className: 'mcpm-group', key: 'def' }, [
|
|
1029
|
+
h('div', { className: 'mcpm-group-title', key: 't' }, t('detail.definition')),
|
|
1030
|
+
h(DefinitionList, { key: 'l', server: server.canonical, reveal }),
|
|
1031
|
+
]),
|
|
1032
|
+
|
|
1033
|
+
snippet ? h('div', { className: 'mcpm-group', key: 'snip' }, [
|
|
1034
|
+
h('div', { className: 'mcpm-group-title', key: 't' }, t('detail.snippetTitle')),
|
|
1035
|
+
h('pre', { className: 'mcpm-pre', key: 'p' }, snippet),
|
|
1036
|
+
h('div', { key: 'a' }, h(CopyButton, { t, text: snippet })),
|
|
1037
|
+
]) : null,
|
|
1038
|
+
|
|
1039
|
+
syncOpen ? h('div', { className: 'mcpm-group', key: 'sync' }, [
|
|
1040
|
+
h('div', { className: 'mcpm-group-title', key: 't' }, t('sync.title', { name: server.name })),
|
|
1041
|
+
otherTargets.length === 0
|
|
1042
|
+
? h('div', { className: 'mcpm-muted', key: 'n' }, t('sync.none'))
|
|
1043
|
+
: otherTargets.map((client) => h('label', { className: 'mcpm-check', key: client.id }, [
|
|
1044
|
+
h('input', {
|
|
1045
|
+
key: 'i',
|
|
1046
|
+
type: 'checkbox',
|
|
1047
|
+
checked: syncPicks.some((pick) => pick.clientId === client.id),
|
|
1048
|
+
onChange: (event) => {
|
|
1049
|
+
setSyncPicks((current) => (event.target.checked
|
|
1050
|
+
? [...current, { clientId: client.id, scope: 'global' }]
|
|
1051
|
+
: current.filter((pick) => pick.clientId !== client.id)));
|
|
1052
|
+
},
|
|
1053
|
+
}),
|
|
1054
|
+
client.label?.en ?? client.id,
|
|
1055
|
+
])),
|
|
1056
|
+
h('div', { key: 'a', style: { display: 'flex', gap: 8 } }, [
|
|
1057
|
+
h('button', {
|
|
1058
|
+
key: 'go', className: 'mcpm-btn primary sm',
|
|
1059
|
+
disabled: busy || syncPicks.length === 0,
|
|
1060
|
+
onClick: () => doSync(server),
|
|
1061
|
+
}, t('btn.sync')),
|
|
1062
|
+
h('button', { key: 'x', className: 'mcpm-btn sm quiet', onClick: () => setSyncOpen(false) }, t('btn.cancel')),
|
|
1063
|
+
]),
|
|
1064
|
+
]) : null,
|
|
1065
|
+
|
|
1066
|
+
server.targets.some((target) => target.server?.secrets?.length)
|
|
1067
|
+
? h('div', { className: 'mcpm-banner warn', key: 'sec' }, t('detail.secretWarn'))
|
|
1068
|
+
: null,
|
|
1069
|
+
]),
|
|
1070
|
+
]));
|
|
1071
|
+
})() : null;
|
|
1072
|
+
|
|
1073
|
+
let body;
|
|
1074
|
+
if (loading && !state) {
|
|
1075
|
+
body = h('div', { className: 'mcpm-empty' }, [h('span', { className: 'mcpm-spin', key: 's' }), ' ', t('state.loading')]);
|
|
1076
|
+
} else if (error && !state) {
|
|
1077
|
+
body = h('div', { className: 'mcpm-error' }, `${t('state.failed')}: ${error.message}`);
|
|
1078
|
+
} else if (state && sources.length === 0) {
|
|
1079
|
+
body = h('div', { className: 'mcpm-empty' }, t('state.empty'));
|
|
1080
|
+
} else if (state) {
|
|
1081
|
+
body = h('div', { className: 'mcpm-sources', key: 'list' }, visible.length === 0
|
|
1082
|
+
? [h('div', { className: 'mcpm-empty', key: 'none' }, t('state.noMatch'))]
|
|
1083
|
+
: visible.map(sourceCard));
|
|
1084
|
+
} else {
|
|
1085
|
+
body = null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return h('div', { className: 'mcpm-root' }, [
|
|
1089
|
+
h('style', { key: 'style' }, CSS),
|
|
1090
|
+
/*
|
|
1091
|
+
* Page content lives inside .mcpm-main so the container-query context
|
|
1092
|
+
* (which implies layout containment) never wraps the dialog below.
|
|
1093
|
+
*/
|
|
1094
|
+
h('div', { className: 'mcpm-main', key: 'main' }, [
|
|
1095
|
+
header,
|
|
1096
|
+
summary,
|
|
1097
|
+
filters,
|
|
1098
|
+
...banners,
|
|
1099
|
+
/*
|
|
1100
|
+
* An error with a live snapshot is an action failure, not a scan failure:
|
|
1101
|
+
* it belongs under the controls, next to the thing that failed, and the
|
|
1102
|
+
* list below stays usable.
|
|
1103
|
+
*/
|
|
1104
|
+
error && state ? h('div', { className: 'mcpm-error', key: 'err' }, h('div', { className: 'mcpm-error-lines' }, [
|
|
1105
|
+
h('div', { key: 'msg' }, `${t('error.title')}: ${error.message}${error.hint ? ` — ${error.hint}` : ''}`),
|
|
1106
|
+
/*
|
|
1107
|
+
* A conflict is not a failure the user can retry blindly: the file
|
|
1108
|
+
* changed under us, so the only correct next step is to re-read it and
|
|
1109
|
+
* decide again. Offer that instead of leaving a dead end.
|
|
1110
|
+
*/
|
|
1111
|
+
error.code === 'error.file.conflict' ? h('div', { key: 'conflict' }, [
|
|
1112
|
+
h('div', { key: 't' }, t('error.conflict')),
|
|
1113
|
+
h('button', { key: 'b', className: 'mcpm-btn sm', onClick: () => load(reveal) }, t('btn.refresh')),
|
|
1114
|
+
]) : null,
|
|
1115
|
+
/*
|
|
1116
|
+
* A credential refusal is actionable: the fix is to write to the global
|
|
1117
|
+
* scope, or to store an indirection instead of the value.
|
|
1118
|
+
*/
|
|
1119
|
+
error.code === 'error.secret.projectScope' ? h('div', { key: 'secret' }, t('error.projectScope')) : null,
|
|
1120
|
+
error.code === 'error.toggle.notMounted' ? h('div', { key: 'mount' }, t('error.mount')) : null,
|
|
1121
|
+
])) : null,
|
|
1122
|
+
body,
|
|
1123
|
+
state ? h('div', { key: 'foot', style: { display: 'flex' } }, h('button', {
|
|
1124
|
+
className: 'mcpm-btn sm quiet',
|
|
1125
|
+
onClick: () => setHistoryOpen(!historyOpen),
|
|
1126
|
+
}, t('btn.history'))) : null,
|
|
1127
|
+
historyCard,
|
|
1128
|
+
]),
|
|
1129
|
+
detailModal,
|
|
1130
|
+
toast ? h('div', { className: 'mcpm-toast', key: 'toast' }, toast) : null,
|
|
1131
|
+
]);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* --------------------------------------------------------------- apply -- */
|
|
1135
|
+
|
|
1136
|
+
return {
|
|
1137
|
+
inject: ['slots', 'locale'],
|
|
1138
|
+
apply(ctx) {
|
|
1139
|
+
const t = makeT(() => ctx.locale.getLocale().active);
|
|
1140
|
+
ctx.effect(() => ctx.locale.register(NS, DICT), 'mcp-manager dictionaries');
|
|
1141
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1142
|
+
name: 'settings.section',
|
|
1143
|
+
id: 'mcp-manager',
|
|
1144
|
+
order: 26,
|
|
1145
|
+
label: () => t('title'),
|
|
1146
|
+
locale: NS,
|
|
1147
|
+
}, (ownerProps) => h(McpManagerPage, { ...ownerProps, __ctx: ctx })));
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
},
|
|
1151
|
+
});
|