@sunt2049/dsh-mcp-manager 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1157 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@sunt2049/dsh-mcp-manager',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const { useState, useEffect, useMemo, useCallback } = React
6
+ const h = React.createElement
7
+
8
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'
9
+
10
+ const C = {
11
+ surface: 'var(--dsw-alias-bg-layer-1)',
12
+ nested: 'var(--dsw-alias-bg-layer-2)',
13
+ border: 'var(--dsw-alias-border-l1)',
14
+ borderStrong: 'var(--dsw-alias-border-l2)',
15
+ text: 'var(--dsw-alias-label-primary)',
16
+ dim: 'var(--dsw-alias-label-secondary)',
17
+ brand: 'var(--dsw-alias-brand-primary)',
18
+ ok: 'var(--dsw-alias-state-success-primary)',
19
+ warn: 'var(--dsw-alias-state-warn-primary)',
20
+ err: 'var(--dsw-alias-state-error-primary)',
21
+ idle: 'var(--dsw-alias-state-idle-primary)',
22
+ }
23
+
24
+ async function getJson(path) {
25
+ try {
26
+ const response = await fetch(path, { headers: { accept: 'application/json' } })
27
+ return await response.json()
28
+ } catch (error) {
29
+ return { ok: false, error: String((error && error.message) || error) }
30
+ }
31
+ }
32
+
33
+ async function postJson(path, body) {
34
+ try {
35
+ const response = await fetch(path, {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ })
40
+ return await response.json()
41
+ } catch (error) {
42
+ return { ok: false, error: String((error && error.message) || error) }
43
+ }
44
+ }
45
+
46
+ function copyText(value) {
47
+ try {
48
+ navigator.clipboard.writeText(String(value))
49
+ return true
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ function endpointOf(server) {
56
+ if (server.transport === 'streamable-http') return server.url || '(未配置 url)'
57
+ if (server.transport === 'stdio') {
58
+ const args = server.args && server.args.length ? ' ' + server.args.join(' ') : ''
59
+ return (server.command || '(未配置 command)') + args
60
+ }
61
+ return server.transport || '(未知传输)'
62
+ }
63
+
64
+ function statusOf(server) {
65
+ if (server.mounted === false) return { color: C.warn, label: '尚未挂载' }
66
+ if (!server.transport) return { color: C.err, label: '配置不完整' }
67
+ if (!server.tools.length) return { color: C.warn, label: '无工具' }
68
+ return { color: C.ok, label: '已注册' }
69
+ }
70
+
71
+ function parseEnv(text) {
72
+ const env = {}
73
+ for (const raw of String(text || '').split('\n')) {
74
+ const line = raw.trim()
75
+ if (!line || line.startsWith('#')) continue
76
+ const cut = line.indexOf('=')
77
+ if (cut <= 0) continue
78
+ env[line.slice(0, cut).trim()] = line.slice(cut + 1)
79
+ }
80
+ return env
81
+ }
82
+
83
+ function verdictLabel(verdict) {
84
+ switch (verdict) {
85
+ case 'port-closed':
86
+ return '端口不通'
87
+ case 'handshake-failed':
88
+ return '端口通,握手失败'
89
+ case 'handshake-ok':
90
+ return '握手成功'
91
+ case 'files-missing':
92
+ return '启动文件缺失'
93
+ case 'files-ok':
94
+ return '启动文件齐全'
95
+ case 'bad-config':
96
+ return '配置不完整'
97
+ default:
98
+ return '无法判断'
99
+ }
100
+ }
101
+
102
+ const S = {
103
+ root: { display: 'flex', flexDirection: 'column', gap: '14px', color: C.text, fontSize: '13px' },
104
+ head: { display: 'flex', alignItems: 'flex-start', gap: '12px', flexWrap: 'wrap' },
105
+ title: { margin: 0, fontSize: '16px', fontWeight: 650, letterSpacing: '.2px' },
106
+ sub: { margin: '3px 0 0', color: C.dim, fontSize: '12px' },
107
+ spacer: { flex: 1 },
108
+ btn: {
109
+ appearance: 'none',
110
+ border: `1px solid ${C.border}`,
111
+ background: C.surface,
112
+ color: C.text,
113
+ borderRadius: '8px',
114
+ padding: '5px 11px',
115
+ fontSize: '12px',
116
+ cursor: 'pointer',
117
+ fontFamily: 'inherit',
118
+ lineHeight: 1.5,
119
+ },
120
+ danger: { borderColor: C.err, color: C.err },
121
+ input: {
122
+ width: '100%',
123
+ boxSizing: 'border-box',
124
+ border: `1px solid ${C.border}`,
125
+ background: C.nested,
126
+ color: C.text,
127
+ borderRadius: '8px',
128
+ padding: '7px 10px',
129
+ fontSize: '12px',
130
+ fontFamily: 'inherit',
131
+ outline: 'none',
132
+ },
133
+ card: { border: `1px solid ${C.border}`, background: C.surface, borderRadius: '12px', overflow: 'hidden' },
134
+ cardHead: {
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ gap: '10px',
138
+ width: '100%',
139
+ textAlign: 'left',
140
+ padding: '11px 13px',
141
+ background: 'transparent',
142
+ border: 0,
143
+ color: C.text,
144
+ cursor: 'pointer',
145
+ fontFamily: 'inherit',
146
+ fontSize: '13px',
147
+ },
148
+ dot: { width: '8px', height: '8px', borderRadius: '50%', flex: '0 0 auto' },
149
+ name: { fontWeight: 620, flex: '0 0 auto' },
150
+ chip: {
151
+ border: `1px solid ${C.border}`,
152
+ color: C.dim,
153
+ borderRadius: '999px',
154
+ padding: '1px 8px',
155
+ fontSize: '11px',
156
+ flex: '0 0 auto',
157
+ fontFamily: MONO,
158
+ },
159
+ endpoint: {
160
+ fontFamily: MONO,
161
+ fontSize: '11.5px',
162
+ color: C.dim,
163
+ overflow: 'hidden',
164
+ textOverflow: 'ellipsis',
165
+ whiteSpace: 'nowrap',
166
+ flex: '1 1 auto',
167
+ minWidth: 0,
168
+ },
169
+ count: { color: C.dim, fontSize: '11.5px', flex: '0 0 auto' },
170
+ body: { borderTop: `1px solid ${C.border}`, padding: '6px 8px 8px' },
171
+ meta: {
172
+ display: 'flex',
173
+ flexWrap: 'wrap',
174
+ gap: '6px 14px',
175
+ padding: '2px 7px 8px',
176
+ color: C.dim,
177
+ fontSize: '11.5px',
178
+ },
179
+ tool: {
180
+ display: 'flex',
181
+ gap: '10px',
182
+ alignItems: 'baseline',
183
+ width: '100%',
184
+ textAlign: 'left',
185
+ border: 0,
186
+ background: 'transparent',
187
+ color: C.text,
188
+ padding: '7px 8px',
189
+ borderRadius: '7px',
190
+ cursor: 'pointer',
191
+ fontFamily: 'inherit',
192
+ },
193
+ toolName: { fontFamily: MONO, fontSize: '12px', color: C.brand, flex: '0 0 auto' },
194
+ toolDesc: {
195
+ color: C.dim,
196
+ fontSize: '11.5px',
197
+ overflow: 'hidden',
198
+ textOverflow: 'ellipsis',
199
+ whiteSpace: 'nowrap',
200
+ flex: '1 1 auto',
201
+ minWidth: 0,
202
+ },
203
+ pre: {
204
+ margin: '2px 8px 8px',
205
+ padding: '10px 12px',
206
+ borderRadius: '8px',
207
+ border: `1px solid ${C.border}`,
208
+ background: C.nested,
209
+ color: C.text,
210
+ fontFamily: MONO,
211
+ fontSize: '11.5px',
212
+ lineHeight: 1.55,
213
+ whiteSpace: 'pre-wrap',
214
+ wordBreak: 'break-word',
215
+ maxHeight: '340px',
216
+ overflow: 'auto',
217
+ },
218
+ notice: {
219
+ border: `1px solid ${C.border}`,
220
+ borderLeft: `3px solid ${C.warn}`,
221
+ background: C.nested,
222
+ borderRadius: '8px',
223
+ padding: '8px 11px',
224
+ fontSize: '12px',
225
+ color: C.dim,
226
+ },
227
+ error: { borderLeftColor: C.err, color: C.err },
228
+ form: {
229
+ display: 'grid',
230
+ gap: '9px',
231
+ padding: '12px',
232
+ borderTop: `1px solid ${C.border}`,
233
+ background: C.nested,
234
+ },
235
+ field: { display: 'grid', gap: '4px' },
236
+ label: { fontSize: '11.5px', color: C.dim },
237
+ row: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' },
238
+ empty: { color: C.dim, fontSize: '12px', padding: '10px 12px' },
239
+ }
240
+
241
+ function Chip(props) {
242
+ return h('span', { style: S.chip }, props.children)
243
+ }
244
+
245
+ function Notice(props) {
246
+ return h('div', { style: props.error ? { ...S.notice, ...S.error } : S.notice }, props.children)
247
+ }
248
+
249
+ function SchemaBlock(props) {
250
+ const entry = props.entry
251
+ if (!entry) return null
252
+ if (entry.state === 'loading') return h('div', { style: S.empty }, '正在读取 schema…')
253
+ if (entry.state === 'error') return h(Notice, { error: true }, entry.error)
254
+
255
+ const json = JSON.stringify(entry.parameters, null, 2)
256
+ return h(
257
+ 'div',
258
+ { style: { display: 'grid', gap: '6px' } },
259
+ h(
260
+ 'div',
261
+ { style: { ...S.row, padding: '0 8px' } },
262
+ h('span', { style: { color: C.dim, fontSize: '11.5px' } }, 'JSON Schema · parameters'),
263
+ h('span', { style: S.spacer }),
264
+ h(
265
+ 'button',
266
+ { style: { ...S.btn, padding: '2px 9px', fontSize: '11.5px' }, onClick: () => copyText(json) },
267
+ '复制',
268
+ ),
269
+ ),
270
+ h('pre', { style: S.pre }, json),
271
+ )
272
+ }
273
+
274
+ function field(label, control) {
275
+ return h('div', { style: S.field }, h('span', { style: S.label }, label), control)
276
+ }
277
+
278
+ function EditForm(props) {
279
+ const server = props.server
280
+ const isHttp = server.transport === 'streamable-http'
281
+ const [url, setUrl] = useState(server.url || '')
282
+ const [command, setCommand] = useState(server.command || '')
283
+ const [argsText, setArgsText] = useState((server.args || []).join('\n'))
284
+ const [timeout, setTimeoutMs] = useState(
285
+ server.toolCallTimeoutMs === null ? '' : String(server.toolCallTimeoutMs),
286
+ )
287
+ const [failFast, setFailFast] = useState(server.failOnStartupError === true)
288
+ const [busy, setBusy] = useState(false)
289
+ const [failure, setFailure] = useState(null)
290
+
291
+ const save = useCallback(async () => {
292
+ const patch = { failOnStartupError: failFast }
293
+ if (timeout.trim() !== '') {
294
+ const value = Number(timeout)
295
+ if (!Number.isFinite(value) || value <= 0) {
296
+ setFailure('超时必须是正数毫秒')
297
+ return
298
+ }
299
+ patch.toolCallTimeoutMs = value
300
+ }
301
+ if (isHttp) {
302
+ if (!url.trim()) {
303
+ setFailure('url 不能为空')
304
+ return
305
+ }
306
+ patch.url = url.trim()
307
+ } else {
308
+ if (!command.trim()) {
309
+ setFailure('command 不能为空')
310
+ return
311
+ }
312
+ patch.command = command.trim()
313
+ patch.args = argsText
314
+ .split('\n')
315
+ .map((line) => line.trim())
316
+ .filter((line) => line.length > 0)
317
+ }
318
+ setBusy(true)
319
+ setFailure(null)
320
+ const result = await postJson('/mcp-manager/save', { id: server.id, patch })
321
+ setBusy(false)
322
+ if (!result || !result.ok) {
323
+ setFailure((result && result.error) || '保存失败')
324
+ return
325
+ }
326
+ props.onSaved()
327
+ }, [argsText, command, failFast, isHttp, props, timeout, url])
328
+
329
+ return h(
330
+ 'div',
331
+ { style: S.form },
332
+ isHttp
333
+ ? field(
334
+ 'url(streamable-http)',
335
+ h('input', { style: S.input, value: url, onChange: (e) => setUrl(e.target.value), spellCheck: false }),
336
+ )
337
+ : h(
338
+ React.Fragment,
339
+ null,
340
+ field(
341
+ 'command(stdio)',
342
+ h('input', {
343
+ style: S.input,
344
+ value: command,
345
+ onChange: (e) => setCommand(e.target.value),
346
+ spellCheck: false,
347
+ }),
348
+ ),
349
+ field(
350
+ 'args(每行一个)',
351
+ h('textarea', {
352
+ style: { ...S.input, minHeight: '62px', resize: 'vertical' },
353
+ value: argsText,
354
+ onChange: (e) => setArgsText(e.target.value),
355
+ spellCheck: false,
356
+ }),
357
+ ),
358
+ ),
359
+ field(
360
+ 'toolCallTimeoutMs(留空表示用默认 60000)',
361
+ h('input', {
362
+ style: S.input,
363
+ value: timeout,
364
+ onChange: (e) => setTimeoutMs(e.target.value),
365
+ inputMode: 'numeric',
366
+ spellCheck: false,
367
+ }),
368
+ ),
369
+ h(
370
+ 'label',
371
+ { style: { ...S.row, gap: '7px', fontSize: '12px', color: C.dim, cursor: 'pointer' } },
372
+ h('input', { type: 'checkbox', checked: failFast, onChange: (e) => setFailFast(e.target.checked) }),
373
+ '连接失败时拒绝激活(failOnStartupError)',
374
+ ),
375
+ server.envKeys.length || server.headerKeys.length
376
+ ? h(Notice, null, 'env / headers 的值不在页面回显,以免泄露密钥;这两项请直接改 patch。')
377
+ : null,
378
+ failure ? h(Notice, { error: true }, failure) : null,
379
+ h(
380
+ 'div',
381
+ { style: S.row },
382
+ h(
383
+ 'button',
384
+ { style: { ...S.btn, borderColor: C.brand, color: C.brand }, onClick: save, disabled: busy },
385
+ busy ? '保存中…' : '保存(热应用)',
386
+ ),
387
+ h('button', { style: S.btn, onClick: props.onCancel, disabled: busy }, '取消'),
388
+ ),
389
+ )
390
+ }
391
+
392
+ function AddForm(props) {
393
+ const [serverName, setServerName] = useState('')
394
+ const [transport, setTransport] = useState('streamable-http')
395
+ const [url, setUrl] = useState('')
396
+ const [command, setCommand] = useState('')
397
+ const [argsText, setArgsText] = useState('')
398
+ const [envText, setEnvText] = useState('')
399
+ const [timeout, setTimeoutMs] = useState('')
400
+ const [busy, setBusy] = useState(false)
401
+ const [failure, setFailure] = useState(null)
402
+
403
+ const submit = useCallback(async () => {
404
+ const server = { serverName: serverName.trim(), transport }
405
+ if (transport === 'streamable-http') server.url = url.trim()
406
+ else {
407
+ server.command = command.trim()
408
+ server.args = argsText.split('\n').map((line) => line.trim()).filter(Boolean)
409
+ const env = parseEnv(envText)
410
+ if (Object.keys(env).length > 0) server.env = env
411
+ }
412
+ if (timeout.trim() !== '') server.toolCallTimeoutMs = Number(timeout)
413
+ setBusy(true)
414
+ setFailure(null)
415
+ const result = await postJson('/mcp-manager/rows', { action: 'add', server })
416
+ setBusy(false)
417
+ if (!result || !result.ok) {
418
+ setFailure((result && result.error) || '添加失败')
419
+ return
420
+ }
421
+ props.onDone()
422
+ }, [argsText, command, envText, props, serverName, timeout, transport, url])
423
+
424
+ const isHttp = transport === 'streamable-http'
425
+ return h(
426
+ 'div',
427
+ { style: { ...S.card, marginTop: '2px' } },
428
+ h('div', { style: { ...S.cardHead, cursor: 'default', fontWeight: 620 } }, '添加 MCP 服务器'),
429
+ h(
430
+ 'div',
431
+ { style: S.form },
432
+ field(
433
+ 'serverName(工具将命名为 mcp__<serverName>__*)',
434
+ h('input', { style: S.input, value: serverName, onChange: (e) => setServerName(e.target.value), spellCheck: false }),
435
+ ),
436
+ field(
437
+ 'transport',
438
+ h(
439
+ 'select',
440
+ {
441
+ style: S.input,
442
+ value: transport,
443
+ onChange: (e) => setTransport(e.target.value),
444
+ },
445
+ h('option', { value: 'streamable-http' }, 'streamable-http'),
446
+ h('option', { value: 'stdio' }, 'stdio'),
447
+ ),
448
+ ),
449
+ isHttp
450
+ ? field('url', h('input', { style: S.input, value: url, onChange: (e) => setUrl(e.target.value), spellCheck: false }))
451
+ : h(
452
+ React.Fragment,
453
+ null,
454
+ field(
455
+ 'command',
456
+ h('input', { style: S.input, value: command, onChange: (e) => setCommand(e.target.value), spellCheck: false }),
457
+ ),
458
+ field(
459
+ 'args(每行一个)',
460
+ h('textarea', {
461
+ style: { ...S.input, minHeight: '58px', resize: 'vertical' },
462
+ value: argsText,
463
+ onChange: (e) => setArgsText(e.target.value),
464
+ spellCheck: false,
465
+ }),
466
+ ),
467
+ field(
468
+ 'env(每行 KEY=VALUE,可留空)',
469
+ h('textarea', {
470
+ style: { ...S.input, minHeight: '58px', resize: 'vertical' },
471
+ value: envText,
472
+ onChange: (e) => setEnvText(e.target.value),
473
+ spellCheck: false,
474
+ }),
475
+ ),
476
+ ),
477
+ field(
478
+ 'toolCallTimeoutMs(可留空)',
479
+ h('input', {
480
+ style: S.input,
481
+ value: timeout,
482
+ onChange: (e) => setTimeoutMs(e.target.value),
483
+ inputMode: 'numeric',
484
+ spellCheck: false,
485
+ }),
486
+ ),
487
+ failure ? h(Notice, { error: true }, failure) : null,
488
+ h(
489
+ 'div',
490
+ { style: S.row },
491
+ h(
492
+ 'button',
493
+ { style: { ...S.btn, borderColor: C.brand, color: C.brand }, onClick: submit, disabled: busy },
494
+ busy ? '写入中…' : '写入 patch',
495
+ ),
496
+ h('button', { style: S.btn, onClick: props.onCancel, disabled: busy }, '取消'),
497
+ ),
498
+ h(Notice, null, '新行会追加到 profile patch 末尾,保存后通常几秒内自动挂载。'),
499
+ ),
500
+ )
501
+ }
502
+
503
+ const PAGE_SIZES = [5, 10, 20, 50, 100]
504
+ const AUTO_REFRESH_MS = 4000
505
+
506
+ /** `1 … 4 5 6 … 20` — a window around the current page plus both ends. */
507
+ function pageItems(current, total) {
508
+ const items = [1]
509
+ const start = Math.max(2, current - 1)
510
+ const end = Math.min(total - 1, current + 1)
511
+ if (start > 2) items.push('…')
512
+ for (let page = start; page <= end; page += 1) items.push(page)
513
+ if (end < total - 1) items.push('…')
514
+ if (total > 1) items.push(total)
515
+ return items
516
+ }
517
+
518
+ function pageButton(label, enabled, onClick) {
519
+ return h(
520
+ 'button',
521
+ {
522
+ style: { ...S.btn, padding: '2px 8px', fontSize: '12px', opacity: enabled ? 1 : 0.45 },
523
+ disabled: !enabled,
524
+ onClick,
525
+ },
526
+ label,
527
+ )
528
+ }
529
+
530
+ function ServerCard(props) {
531
+ const server = props.server
532
+ const open = props.open
533
+ const status = statusOf(server)
534
+ const removable = server.inPatch
535
+ const [page, setPage] = useState(1)
536
+ const [pageSize, setPageSize] = useState(PAGE_SIZES[0])
537
+ const [toolQuery, setToolQuery] = useState('')
538
+
539
+ const tools = useMemo(() => {
540
+ const needle = toolQuery.trim().toLowerCase()
541
+ if (!needle) return server.tools
542
+ return server.tools.filter(
543
+ (tool) =>
544
+ tool.wire.toLowerCase().includes(needle) || tool.description.toLowerCase().includes(needle),
545
+ )
546
+ }, [server.tools, toolQuery])
547
+
548
+ const totalPages = Math.max(1, Math.ceil(tools.length / pageSize))
549
+ const current = Math.min(page, totalPages)
550
+ const from = (current - 1) * pageSize
551
+ const visible = tools.slice(from, from + pageSize)
552
+
553
+ return h(
554
+ 'div',
555
+ { style: S.card },
556
+ h(
557
+ 'button',
558
+ { style: S.cardHead, onClick: props.onToggle, 'aria-expanded': open },
559
+ h('span', { style: { ...S.dot, background: status.color }, title: status.label }),
560
+ h('span', { style: S.name }, server.serverName || server.id),
561
+ h(Chip, null, server.transport || '—'),
562
+ h('span', { style: S.endpoint, title: endpointOf(server) }, endpointOf(server)),
563
+ h('span', { style: S.count }, server.tools.length + ' 工具'),
564
+ h('span', { style: { color: C.dim, fontSize: '11px' } }, open ? '收起' : '展开'),
565
+ ),
566
+ open
567
+ ? h(
568
+ 'div',
569
+ { style: S.body },
570
+ h(
571
+ 'div',
572
+ { style: S.meta },
573
+ h('span', null, 'id: ' + server.id),
574
+ server.mounted === false ? h('span', null, '(已写入,等待挂载…)') : null,
575
+ server.cwd ? h('span', null, 'cwd: ' + server.cwd) : null,
576
+ server.toolCallTimeoutMs === null
577
+ ? null
578
+ : h('span', null, '超时: ' + server.toolCallTimeoutMs + 'ms'),
579
+ server.envKeys.length ? h('span', null, 'env: ' + server.envKeys.join(', ')) : null,
580
+ server.headerKeys.length ? h('span', null, 'headers: ' + server.headerKeys.join(', ')) : null,
581
+ server.failOnStartupError ? h('span', null, 'failOnStartupError') : null,
582
+ server.inPatch ? null : h('span', null, '(不在本 profile 的 patch 里)'),
583
+ ),
584
+ server.tools.length === 0
585
+ ? h(
586
+ 'div',
587
+ { style: S.empty },
588
+ h(
589
+ 'div',
590
+ null,
591
+ '这个服务器没有向工具注册表贡献任何 mcp__ 工具:可能没连上、握手失败,或它本身不提供 tools。',
592
+ ),
593
+ props.probe && props.probe.state === 'running'
594
+ ? h('div', { style: { marginTop: '6px' } }, '正在探测…')
595
+ : props.probe && props.probe.state === 'error'
596
+ ? h('div', { style: { marginTop: '6px', color: C.err } }, props.probe.detail)
597
+ : props.probe && props.probe.state === 'done'
598
+ ? h(
599
+ 'div',
600
+ {
601
+ style: {
602
+ marginTop: '6px',
603
+ color:
604
+ props.probe.verdict === 'handshake-ok' || props.probe.verdict === 'files-ok'
605
+ ? C.ok
606
+ : props.probe.verdict === 'handshake-failed'
607
+ ? C.warn
608
+ : C.err,
609
+ },
610
+ },
611
+ verdictLabel(props.probe.verdict) + ' · ' + props.probe.detail,
612
+ )
613
+ : null,
614
+ )
615
+ : [
616
+ server.tools.length > 12
617
+ ? h('input', {
618
+ key: 'tool-filter',
619
+ style: { ...S.input, margin: '2px 8px 6px' },
620
+ placeholder: '在本服务器的 ' + server.tools.length + ' 个工具里筛选…',
621
+ value: toolQuery,
622
+ onChange: (event) => {
623
+ setToolQuery(event.target.value)
624
+ setPage(1)
625
+ },
626
+ spellCheck: false,
627
+ })
628
+ : null,
629
+ tools.length === 0
630
+ ? h('div', { key: 'no-match', style: S.empty }, '没有匹配的工具。')
631
+ : visible.map((tool) =>
632
+ h(
633
+ 'div',
634
+ { key: tool.name },
635
+ h(
636
+ 'button',
637
+ { style: S.tool, onClick: () => props.onToggleTool(tool.name) },
638
+ h('span', { style: S.toolName }, tool.wire),
639
+ h(
640
+ 'span',
641
+ { style: S.toolDesc, title: tool.description },
642
+ tool.description || '(无描述)',
643
+ ),
644
+ h(
645
+ 'span',
646
+ { style: { color: C.dim, fontSize: '11px' } },
647
+ props.openTool === tool.name ? '收回' : '参数',
648
+ ),
649
+ ),
650
+ props.openTool === tool.name
651
+ ? h(SchemaBlock, { entry: props.schemas[tool.name] })
652
+ : null,
653
+ ),
654
+ ),
655
+ tools.length > 0
656
+ ? h(
657
+ 'div',
658
+ {
659
+ key: 'pager',
660
+ style: {
661
+ ...S.row,
662
+ gap: '4px',
663
+ padding: '8px 8px 2px',
664
+ borderTop: `1px solid ${C.border}`,
665
+ marginTop: '6px',
666
+ },
667
+ },
668
+ h(
669
+ 'span',
670
+ { style: { color: C.dim, fontSize: '11.5px' } },
671
+ '共 ' + tools.length + ' 个',
672
+ ),
673
+ h('span', { style: S.spacer }),
674
+ totalPages > 1
675
+ ? h(
676
+ React.Fragment,
677
+ null,
678
+ pageButton('首页', current > 1, () => setPage(1), S),
679
+ pageButton('上一页', current > 1, () => setPage(current - 1), S),
680
+ pageItems(current, totalPages).map((item, index) =>
681
+ item === '…'
682
+ ? h(
683
+ 'span',
684
+ {
685
+ key: 'gap' + index,
686
+ style: { color: C.dim, padding: '0 3px', fontSize: '12px' },
687
+ },
688
+ '…',
689
+ )
690
+ : h(
691
+ 'button',
692
+ {
693
+ key: 'p' + item,
694
+ style: {
695
+ ...S.btn,
696
+ padding: '2px 8px',
697
+ minWidth: '26px',
698
+ fontSize: '12px',
699
+ ...(item === current
700
+ ? {
701
+ borderColor: C.brand,
702
+ color: C.brand,
703
+ fontWeight: 700,
704
+ textDecoration: 'underline',
705
+ }
706
+ : {}),
707
+ },
708
+ onClick: () => setPage(item),
709
+ },
710
+ String(item),
711
+ ),
712
+ ),
713
+ pageButton('下一页', current < totalPages, () => setPage(current + 1), S),
714
+ pageButton('尾页', current < totalPages, () => setPage(totalPages), S),
715
+ )
716
+ : null,
717
+ h(
718
+ 'span',
719
+ { style: { color: C.dim, fontSize: '11.5px', marginLeft: '6px' } },
720
+ '每页',
721
+ ),
722
+ h(
723
+ 'select',
724
+ {
725
+ style: { ...S.btn, padding: '1px 4px', fontSize: '11.5px' },
726
+ value: String(pageSize),
727
+ onChange: (event) => {
728
+ setPageSize(Number(event.target.value))
729
+ setPage(1)
730
+ },
731
+ },
732
+ PAGE_SIZES.map((size) => h('option', { key: size, value: String(size) }, String(size))),
733
+ ),
734
+ h('span', { style: { color: C.dim, fontSize: '11.5px' } }, '个'),
735
+ )
736
+ : null,
737
+ ],
738
+ h(
739
+ 'div',
740
+ { style: { ...S.row, padding: '8px 6px 2px' } },
741
+ h(
742
+ 'button',
743
+ {
744
+ style: S.btn,
745
+ onClick: props.onEdit,
746
+ disabled: server.mounted === false,
747
+ title:
748
+ server.mounted === false
749
+ ? '这一行还没挂载,configEditor 改不到它;等它挂上(通常几秒)再编辑'
750
+ : '通过 configEditor 修改,即时生效',
751
+ },
752
+ props.editing ? '正在编辑' : '编辑配置',
753
+ ),
754
+ h('button', { style: S.btn, onClick: () => copyText(endpointOf(server)) }, '复制端点'),
755
+ h(
756
+ 'button',
757
+ {
758
+ style: S.btn,
759
+ onClick: props.onProbe,
760
+ disabled: props.probe && props.probe.state === 'running',
761
+ },
762
+ props.probe && props.probe.state === 'running' ? '探测中…' : '探测连通性',
763
+ ),
764
+ h('span', { style: S.spacer }),
765
+ h(
766
+ 'button',
767
+ {
768
+ style: S.btn,
769
+ onClick: props.onDisable,
770
+ disabled: !removable || props.busy,
771
+ title: removable ? '从 patch 里摘掉并暂存,可随时恢复' : '这一行不在本 profile 的 patch 文件里',
772
+ },
773
+ '停用',
774
+ ),
775
+ props.confirm
776
+ ? h(
777
+ React.Fragment,
778
+ null,
779
+ h(
780
+ 'button',
781
+ { style: { ...S.btn, ...S.danger }, onClick: props.onDelete, disabled: props.busy },
782
+ props.busy ? '删除中…' : '确认删除',
783
+ ),
784
+ h('button', { style: S.btn, onClick: props.onCancelDelete, disabled: props.busy }, '取消'),
785
+ )
786
+ : h(
787
+ 'button',
788
+ {
789
+ style: S.btn,
790
+ onClick: props.onAskDelete,
791
+ disabled: !removable || props.busy,
792
+ title: removable ? '从 patch 里永久删除这一行' : '这一行不在本 profile 的 patch 文件里',
793
+ },
794
+ '删除',
795
+ ),
796
+ ),
797
+ )
798
+ : null,
799
+ props.editing ? h(EditForm, { server, onCancel: props.onCancelEdit, onSaved: props.onSaved }) : null,
800
+ )
801
+ }
802
+
803
+ function ConsoleSection() {
804
+ const [data, setData] = useState(null)
805
+ const [failure, setFailure] = useState(null)
806
+ const [loading, setLoading] = useState(true)
807
+ const [query, setQuery] = useState('')
808
+ const [openIds, setOpenIds] = useState({})
809
+ const [openTool, setOpenTool] = useState(null)
810
+ const [schemas, setSchemas] = useState({})
811
+ const [editingId, setEditingId] = useState(null)
812
+ const [adding, setAdding] = useState(false)
813
+ const [confirmId, setConfirmId] = useState(null)
814
+ const [busyId, setBusyId] = useState(null)
815
+ const [probes, setProbes] = useState({})
816
+ const [auto, setAuto] = useState(true)
817
+ const [syncedAt, setSyncedAt] = useState(null)
818
+ const inflight = React.useRef(false)
819
+
820
+ const refresh = useCallback(async (silent) => {
821
+ if (inflight.current) return
822
+ inflight.current = true
823
+ if (!silent) setLoading(true)
824
+ const result = await getJson('/mcp-manager/overview')
825
+ inflight.current = false
826
+ if (!silent) setLoading(false)
827
+ if (!result || !result.ok) {
828
+ // A background tick must not wipe a working panel over one bad read.
829
+ if (!silent) setFailure((result && result.error) || '读取失败')
830
+ return
831
+ }
832
+ setFailure(null)
833
+ setData(result)
834
+ setSyncedAt(new Date())
835
+ }, [])
836
+
837
+ useEffect(() => {
838
+ refresh(false)
839
+ }, [refresh])
840
+
841
+ // The Loader applies patch edits through HMR within a few seconds, so the
842
+ // panel re-reads on its own instead of making the user hunt for 刷新.
843
+ useEffect(() => {
844
+ if (!auto) return undefined
845
+ const timer = setInterval(() => {
846
+ if (document.hidden) return
847
+ refresh(true)
848
+ }, AUTO_REFRESH_MS)
849
+ return () => clearInterval(timer)
850
+ }, [auto, refresh])
851
+
852
+ const runProbe = useCallback(async (server) => {
853
+ setProbes((prev) => ({ ...prev, [server.id]: { state: 'running' } }))
854
+ const result = await postJson('/mcp-manager/probe', { id: server.id })
855
+ if (!result || !result.ok) {
856
+ setProbes((prev) => ({
857
+ ...prev,
858
+ [server.id]: { state: 'error', detail: (result && result.error) || '探测失败' },
859
+ }))
860
+ return
861
+ }
862
+ setProbes((prev) => ({
863
+ ...prev,
864
+ [server.id]: { state: 'done', verdict: result.verdict, detail: result.detail },
865
+ }))
866
+ }, [])
867
+
868
+ const loadSchema = useCallback(async (name) => {
869
+ setSchemas((prev) =>
870
+ prev[name] && prev[name].state === 'ok' ? prev : { ...prev, [name]: { state: 'loading' } },
871
+ )
872
+ const result = await getJson('/mcp-manager/schema?tool=' + encodeURIComponent(name))
873
+ if (!result || !result.ok) {
874
+ setSchemas((prev) => ({
875
+ ...prev,
876
+ [name]: { state: 'error', error: (result && result.error) || '读取 schema 失败' },
877
+ }))
878
+ return
879
+ }
880
+ setSchemas((prev) => ({
881
+ ...prev,
882
+ [name]: { state: 'ok', parameters: result.parameters, description: result.description },
883
+ }))
884
+ }, [])
885
+
886
+ const toggleTool = useCallback(
887
+ (name) => {
888
+ setOpenTool((current) => {
889
+ if (current === name) return null
890
+ loadSchema(name)
891
+ return name
892
+ })
893
+ },
894
+ [loadSchema],
895
+ )
896
+
897
+ const toggleServer = useCallback(
898
+ (server) => {
899
+ const willOpen = openIds[server.id] !== true
900
+ setOpenIds((prev) => ({ ...prev, [server.id]: willOpen }))
901
+ // A server with no tools is exactly the case where the user wants a
902
+ // verdict instead of a guess, so ask once on expand.
903
+ if (willOpen && server.tools.length === 0 && !probes[server.id]) runProbe(server)
904
+ },
905
+ [openIds, probes, runProbe],
906
+ )
907
+
908
+ const exportJson = useCallback(async () => {
909
+ const result = await getJson('/mcp-manager/export')
910
+ if (!result || !result.ok) {
911
+ setFailure((result && result.error) || '导出失败')
912
+ return
913
+ }
914
+ copyText(JSON.stringify(result, null, 2))
915
+ }, [])
916
+
917
+ const act = useCallback(
918
+ async (action, payload) => {
919
+ const key = payload && payload.id ? payload.id : 'add'
920
+ setBusyId(key)
921
+ setFailure(null)
922
+ const result = await postJson('/mcp-manager/rows', { action, ...payload })
923
+ setBusyId(null)
924
+ if (!result || !result.ok) {
925
+ setFailure((result && result.error) || '操作失败')
926
+ return false
927
+ }
928
+ setEditingId(null)
929
+ setConfirmId(null)
930
+ setSchemas({})
931
+ setOpenTool(null)
932
+ await refresh()
933
+ return true
934
+ },
935
+ [refresh],
936
+ )
937
+
938
+ const servers = (data && data.servers) || []
939
+ const parked = (data && data.parked) || []
940
+
941
+ const filtered = useMemo(() => {
942
+ const needle = query.trim().toLowerCase()
943
+ if (!needle) return servers
944
+ return servers
945
+ .map((server) => {
946
+ const nameHit =
947
+ server.serverName.toLowerCase().includes(needle) ||
948
+ server.id.toLowerCase().includes(needle) ||
949
+ endpointOf(server).toLowerCase().includes(needle)
950
+ const tools = server.tools.filter(
951
+ (tool) =>
952
+ tool.wire.toLowerCase().includes(needle) || tool.description.toLowerCase().includes(needle),
953
+ )
954
+ if (nameHit) return server
955
+ if (tools.length) return { ...server, tools }
956
+ return null
957
+ })
958
+ .filter(Boolean)
959
+ }, [query, servers])
960
+
961
+ const style = h(
962
+ 'style',
963
+ null,
964
+ '.mc-card-btn:hover{background:var(--dsw-alias-bg-layer-2)}',
965
+ )
966
+
967
+ const header = h(
968
+ 'div',
969
+ { style: S.head },
970
+ h(
971
+ 'div',
972
+ null,
973
+ h('h3', { style: S.title }, 'MCP 控制台'),
974
+ h(
975
+ 'p',
976
+ { style: S.sub },
977
+ loading && !data
978
+ ? '读取中…'
979
+ : data
980
+ ? data.serverTotal + ' 个服务器 · ' + data.toolTotal + ' 个工具' +
981
+ (parked.length ? ' · ' + parked.length + ' 个已停用' : '') +
982
+ (syncedAt ? ' · 已同步 ' + syncedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '')
983
+ : '未获取到数据',
984
+ ),
985
+ ),
986
+ h('span', { style: S.spacer }),
987
+ h('button', { style: S.btn, onClick: () => setAdding((v) => !v) }, adding ? '收起添加' : '+ 添加'),
988
+ h(
989
+ 'label',
990
+ {
991
+ style: { ...S.btn, display: 'inline-flex', alignItems: 'center', gap: '5px', cursor: 'pointer' },
992
+ title: 'HMR 会在几秒内应用 patch 改动,自动刷新让你不用手动点',
993
+ },
994
+ h('input', {
995
+ type: 'checkbox',
996
+ checked: auto,
997
+ onChange: (event) => setAuto(event.target.checked),
998
+ style: { margin: 0 },
999
+ }),
1000
+ '自动刷新',
1001
+ h(
1002
+ 'span',
1003
+ { style: { color: C.dim, fontSize: '11px' } },
1004
+ auto ? '(' + AUTO_REFRESH_MS / 1000 + 's)' : '',
1005
+ ),
1006
+ ),
1007
+ h('button', { style: S.btn, onClick: () => refresh(false), disabled: loading }, loading ? '刷新中…' : '刷新'),
1008
+ h('button', { style: S.btn, onClick: exportJson }, '复制 JSON'),
1009
+ )
1010
+
1011
+ const patchLine =
1012
+ data && data.patchPath
1013
+ ? h(
1014
+ 'div',
1015
+ { style: { ...S.row, gap: '6px' } },
1016
+ h('span', { style: { color: C.dim, fontSize: '11.5px' } }, '配置来源'),
1017
+ h(
1018
+ 'code',
1019
+ {
1020
+ style: {
1021
+ fontFamily: MONO,
1022
+ fontSize: '11.5px',
1023
+ color: C.dim,
1024
+ overflow: 'hidden',
1025
+ textOverflow: 'ellipsis',
1026
+ whiteSpace: 'nowrap',
1027
+ },
1028
+ title: data.patchPath,
1029
+ },
1030
+ data.patchPath,
1031
+ ),
1032
+ h(
1033
+ 'button',
1034
+ { style: { ...S.btn, padding: '1px 8px', fontSize: '11px' }, onClick: () => copyText(data.patchPath) },
1035
+ '复制',
1036
+ ),
1037
+ )
1038
+ : null
1039
+
1040
+ const pending =
1041
+ data && data.pendingRestart
1042
+ ? h(
1043
+ Notice,
1044
+ null,
1045
+ '已写入 profile patch:HMR 通常几秒内自动生效。若长时间停在“尚未挂载”,才需要重启 DSH。',
1046
+ )
1047
+ : null
1048
+
1049
+ return h(
1050
+ 'div',
1051
+ { style: S.root },
1052
+ style,
1053
+ header,
1054
+ patchLine,
1055
+ pending,
1056
+ adding ? h(AddForm, { onDone: () => { setAdding(false); refresh() }, onCancel: () => setAdding(false) }) : null,
1057
+ h('input', {
1058
+ style: S.input,
1059
+ placeholder: '搜索服务器名、端点或工具名…',
1060
+ value: query,
1061
+ onChange: (event) => setQuery(event.target.value),
1062
+ spellCheck: false,
1063
+ }),
1064
+ failure ? h(Notice, { error: true }, failure) : null,
1065
+ data && data.error ? h(Notice, null, data.error) : null,
1066
+ loading && !data
1067
+ ? h('div', { style: S.empty }, '正在读取 MCP 配置与工具注册表…')
1068
+ : filtered.length === 0
1069
+ ? h('div', { style: S.empty }, query ? '没有匹配的服务器或工具。' : '这个 profile 里没有 MCP 服务器。')
1070
+ : filtered.map((server) =>
1071
+ h(ServerCard, {
1072
+ key: server.id,
1073
+ server,
1074
+ open: openIds[server.id] === true,
1075
+ openTool,
1076
+ schemas,
1077
+ editing: editingId === server.id,
1078
+ confirm: confirmId === server.id,
1079
+ busy: busyId === server.id,
1080
+ searched: query.trim() !== '',
1081
+ probe: probes[server.id],
1082
+ onProbe: () => runProbe(server),
1083
+ onToggle: () => toggleServer(server),
1084
+ onToggleTool: toggleTool,
1085
+ onEdit: () => setEditingId(server.id),
1086
+ onCancelEdit: () => setEditingId(null),
1087
+ onAskDelete: () => setConfirmId(server.id),
1088
+ onCancelDelete: () => setConfirmId(null),
1089
+ onDelete: () => act('remove', { id: server.patchId }),
1090
+ onDisable: () => act('disable', { id: server.patchId }),
1091
+ onSaved: () => {
1092
+ setEditingId(null)
1093
+ setSchemas({})
1094
+ setOpenTool(null)
1095
+ refresh()
1096
+ },
1097
+ }),
1098
+ ),
1099
+ parked.length > 0
1100
+ ? h(
1101
+ 'div',
1102
+ { style: { display: 'grid', gap: '8px' } },
1103
+ h(
1104
+ 'div',
1105
+ { style: { ...S.row, gap: '8px' } },
1106
+ h('span', { style: { fontWeight: 620 } }, '已停用'),
1107
+ h('span', { style: { color: C.dim, fontSize: '11.5px' } }, '暂存在本 profile 的 mcp-manager/disabled.json,可恢复'),
1108
+ ),
1109
+ parked.map((item) =>
1110
+ h(
1111
+ 'div',
1112
+ { key: item.id, style: { ...S.card, padding: '10px 13px', display: 'flex', gap: '10px', alignItems: 'center' } },
1113
+ h('span', { style: { ...S.dot, background: C.idle } }),
1114
+ h('span', { style: S.name }, item.serverName || item.id),
1115
+ h(Chip, null, item.transport || '—'),
1116
+ item.stillMounted ? h(Chip, null, '进程内仍挂着 · 待卸载') : null,
1117
+ h('span', { style: S.endpoint, title: item.url }, item.url || item.id),
1118
+ h('span', { style: S.spacer }),
1119
+ h('span', { style: { color: C.dim, fontSize: '11.5px' } }, item.savedAt ? item.savedAt.slice(0, 19).replace('T', ' ') : ''),
1120
+ h(
1121
+ 'button',
1122
+ { style: S.btn, onClick: () => act('enable', { id: item.id }), disabled: busyId === item.id },
1123
+ busyId === item.id ? '恢复中…' : '启用',
1124
+ ),
1125
+ h(
1126
+ 'button',
1127
+ { style: { ...S.btn, ...S.danger }, onClick: () => act('forget', { id: item.id }), disabled: busyId === item.id },
1128
+ '彻底删除',
1129
+ ),
1130
+ ),
1131
+ ),
1132
+ )
1133
+ : null,
1134
+ )
1135
+ }
1136
+
1137
+ return {
1138
+ name: '@sunt2049/dsh-mcp-manager',
1139
+ inject: ['slots'],
1140
+ apply(ctx) {
1141
+ const slots = ctx.get('slots')
1142
+ if (slots === undefined) return
1143
+ slots.inject('settings.section', () =>
1144
+ slots.register(
1145
+ {
1146
+ name: 'settings.section',
1147
+ id: 'mcp-manager',
1148
+ order: 26,
1149
+ label: () => 'MCP 控制台',
1150
+ },
1151
+ () => ConsoleSection(),
1152
+ ),
1153
+ )
1154
+ },
1155
+ }
1156
+ },
1157
+ })