@sunt2049/dsh-mcp-manager 0.0.0-stage → 0.3.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/index.js ADDED
@@ -0,0 +1,960 @@
1
+ /**
2
+ * @sunt2049/dsh-mcp-manager — Host half.
3
+ *
4
+ * Two write paths, deliberately different:
5
+ *
6
+ * - `ctx.configEditor.edit` for an existing row's config. It persists and
7
+ * reconciles through the Loader, and the MCP client reloads that server's
8
+ * connection in place, so this one is live.
9
+ * - the profile `cordis.patch.yml` document for adding, removing or parking a
10
+ * row. Nothing watches that file, so these changes are persisted only and
11
+ * the panel reports `pendingRestart` until the host restarts.
12
+ *
13
+ * Routes (same-origin, JSON):
14
+ * GET /mcp-manager/overview servers, live tool inventory, parked servers
15
+ * GET /mcp-manager/schema one tool's full JSON Schema, by wire name
16
+ * GET /mcp-manager/export raw configs as JSON
17
+ * POST /mcp-manager/save patch one server's config via configEditor
18
+ * POST /mcp-manager/rows add / remove / disable / enable a patch row
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
22
+ import { createConnection } from 'node:net'
23
+ import { dirname, resolve } from 'node:path'
24
+ import {
25
+ appendRow,
26
+ buildRowLines,
27
+ describeRow,
28
+ insertRow,
29
+ parsePatch,
30
+ removeRow,
31
+ rowIds,
32
+ serializePatch,
33
+ } from './patch-doc.js'
34
+
35
+ const MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'
36
+ const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
37
+
38
+ /** RFC 7230 token, the only thing a header name may contain. */
39
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
40
+
41
+ /** Config keys this console is willing to write back through configEditor. */
42
+ const EDITABLE = new Set([
43
+ 'serverName',
44
+ 'transport',
45
+ 'url',
46
+ 'command',
47
+ 'args',
48
+ 'env',
49
+ 'cwd',
50
+ 'headers',
51
+ 'toolCallTimeoutMs',
52
+ 'failOnStartupError',
53
+ 'maxInstructionBytes',
54
+ ])
55
+
56
+ /** Set once this process writes a patch row; a restart clears it. */
57
+ let rowsTouchedAt = null
58
+
59
+ function asObject(value) {
60
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
61
+ }
62
+
63
+ function text(value) {
64
+ return typeof value === 'string' ? value : ''
65
+ }
66
+
67
+ function clip(value, limit) {
68
+ const body = text(value).replace(/\s+/g, ' ').trim()
69
+ return body.length > limit ? `${body.slice(0, limit - 1)}…` : body
70
+ }
71
+
72
+ function sendJson(response, status, payload) {
73
+ response.writeHead(status, {
74
+ 'content-type': 'application/json; charset=utf-8',
75
+ 'cache-control': 'no-store',
76
+ })
77
+ response.end(JSON.stringify(payload))
78
+ }
79
+
80
+ function readBody(request) {
81
+ return new Promise((done) => {
82
+ let data = ''
83
+ request.on('data', (chunk) => {
84
+ data += String(chunk)
85
+ if (data.length > 2_000_000) request.destroy()
86
+ })
87
+ request.on('end', () => {
88
+ try {
89
+ done(JSON.parse(data))
90
+ } catch {
91
+ done({})
92
+ }
93
+ })
94
+ request.on('error', () => done({}))
95
+ })
96
+ }
97
+
98
+ /** Reject cross-site writes; compare host:port exactly when Origin is present. */
99
+ function trustedOrigin(request) {
100
+ const origin = request.headers.origin
101
+ const host = request.headers.host
102
+ if (typeof origin !== 'string') return true
103
+ if (typeof host !== 'string') return false
104
+ try {
105
+ return new URL(origin).host.toLowerCase() === host.toLowerCase()
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ function entryName(entry) {
112
+ if (!entry) return ''
113
+ return text(entry.name) || text(entry.options && entry.options.name) || ''
114
+ }
115
+
116
+ function isMcpRow(entry, config) {
117
+ if (entryName(entry) === MCP_PLUGIN) return true
118
+ const shape = asObject(config)
119
+ return Boolean(shape.serverName) && Boolean(shape.transport)
120
+ }
121
+
122
+ /** `include:mcp-x` → `mcp-x`: the id this file's `- id:` lines carry. */
123
+ function patchIdOf(entryId) {
124
+ const raw = text(entryId)
125
+ const cut = raw.lastIndexOf(':')
126
+ return cut === -1 ? raw : raw.slice(cut + 1)
127
+ }
128
+
129
+ function parkedPath(patchPath) {
130
+ // Per profile: `profiles/<name>/mcp-manager/disabled.json`. Sharing one store
131
+ // across profiles made a row parked in one profile vanish in every other one.
132
+ return resolve(dirname(patchPath), 'mcp-manager', 'disabled.json')
133
+ }
134
+
135
+ function readParked(patchPath) {
136
+ try {
137
+ const file = parkedPath(patchPath)
138
+ if (!existsSync(file)) return {}
139
+ return asObject(JSON.parse(readFileSync(file, 'utf8')))
140
+ } catch {
141
+ return {}
142
+ }
143
+ }
144
+
145
+ function writeParked(patchPath, parked) {
146
+ const file = parkedPath(patchPath)
147
+ mkdirSync(dirname(file), { recursive: true })
148
+ writeFileSync(file, JSON.stringify(parked, null, 2), 'utf8')
149
+ }
150
+
151
+ function readDocument(patchPath) {
152
+ const source = readFileSync(patchPath, 'utf8')
153
+ const blocks = parsePatch(source)
154
+ return { blocks, lossless: serializePatch(blocks) === source }
155
+ }
156
+
157
+ /**
158
+ * Write the document **in place**. The Loader's HMR watcher holds the file's
159
+ * handle: writing a temp file and renaming it over the original replaces that
160
+ * handle, so the watcher never fires and the change silently waits for a
161
+ * restart. Writing through the same path keeps the watcher alive and the new
162
+ * composition is applied within a few seconds.
163
+ */
164
+ function writeDocument(patchPath, blocks) {
165
+ const body = serializePatch(blocks)
166
+ const tmp = `${patchPath}.mcp-manager.tmp`
167
+ writeFileSync(tmp, body, 'utf8')
168
+ writeFileSync(patchPath, body, 'utf8')
169
+ try {
170
+ unlinkSync(tmp)
171
+ } catch {
172
+ /* the temp file is only a crash breadcrumb */
173
+ }
174
+ }
175
+
176
+ /** Profile rows whose plugin is the official MCP client. */
177
+ function readServers(ctx, patchIds) {
178
+ const out = { patchPath: '', servers: [], error: null }
179
+
180
+ try {
181
+ out.patchPath = ctx.configEditor.documentPath || ''
182
+ } catch (error) {
183
+ out.error = `configEditor.documentPath: ${String((error && error.message) || error)}`
184
+ }
185
+
186
+ let configurations = []
187
+ try {
188
+ configurations = ctx.configEditor.configuration() || []
189
+ } catch (error) {
190
+ out.error = `configEditor.configuration: ${String((error && error.message) || error)}`
191
+ return out
192
+ }
193
+
194
+ for (const row of configurations) {
195
+ const entry = row && row.entry
196
+ if (!entry) continue
197
+
198
+ // A Loader entry keeps its applied config on `options`; `inherited`/`override`
199
+ // carry the layer values (bundle defaults vs. the profile patch).
200
+ const options = asObject(entry.options)
201
+ const applied = asObject(options.config)
202
+ const entryConfig = asObject(entry.config)
203
+ const inherited = asObject(row.inherited)
204
+ const override = asObject(row.override)
205
+ const resolved = Object.keys(applied).length
206
+ ? applied
207
+ : Object.keys(entryConfig).length
208
+ ? entryConfig
209
+ : Object.keys(override).length
210
+ ? { ...inherited, ...override }
211
+ : inherited
212
+
213
+ if (!isMcpRow(entry, resolved)) continue
214
+
215
+ const env = asObject(resolved.env)
216
+ const shortId = patchIdOf(entry.id)
217
+
218
+ out.servers.push({
219
+ id: text(entry.id),
220
+ patchId: shortId,
221
+ serverName: text(resolved.serverName),
222
+ transport: text(resolved.transport),
223
+ url: text(resolved.url),
224
+ command: text(resolved.command),
225
+ args: Array.isArray(resolved.args) ? resolved.args.map((item) => String(item)) : [],
226
+ cwd: text(resolved.cwd),
227
+ envKeys: Object.keys(env),
228
+ headerKeys: Object.keys(asObject(resolved.headers)),
229
+ toolCallTimeoutMs: typeof resolved.toolCallTimeoutMs === 'number' ? resolved.toolCallTimeoutMs : null,
230
+ failOnStartupError: resolved.failOnStartupError === true,
231
+ disabled: options.disabled === true,
232
+ /** True when this row is a `- id:` line of the profile patch document. */
233
+ inPatch: patchIds.has(shortId),
234
+ editable: resolved,
235
+ })
236
+ }
237
+
238
+ out.servers.sort((a, b) => a.serverName.localeCompare(b.serverName))
239
+ return out
240
+ }
241
+
242
+ /**
243
+ * Every server the panel shows: the mounted rows, plus the document's MCP rows
244
+ * that the Loader has not applied yet. `configuration()` lags a write by a
245
+ * moment, so the two views disagree briefly after add/enable; every consumer
246
+ * (list, probe) must read this one.
247
+ */
248
+ function readAllServers(ctx, patchPath) {
249
+ let patchRows = []
250
+ try {
251
+ for (const block of parsePatch(readFileSync(patchPath, 'utf8'))) {
252
+ if (block.kind === 'text') continue
253
+ for (const row of block.rows) if (row.id) patchRows.push(row)
254
+ }
255
+ } catch {
256
+ patchRows = []
257
+ }
258
+
259
+ const patchIds = new Set(patchRows.map((row) => row.id))
260
+ const { servers, error } = readServers(ctx, patchIds)
261
+ const mountedById = new Map(servers.map((server) => [server.patchId, server]))
262
+ const merged = []
263
+ const placed = new Set()
264
+
265
+ for (const row of patchRows) {
266
+ const live = mountedById.get(row.id)
267
+ if (live) {
268
+ placed.add(row.id)
269
+ merged.push({ ...live, mounted: true })
270
+ continue
271
+ }
272
+ const info = describeRow(row.lines)
273
+ // The profile patch also holds every other plugin's row; only the MCP
274
+ // client's rows are servers.
275
+ if (info.name !== MCP_PLUGIN && !info.serverName) continue
276
+ placed.add(row.id)
277
+ merged.push({
278
+ id: row.id,
279
+ patchId: row.id,
280
+ serverName: info.serverName,
281
+ transport: info.transport,
282
+ url: info.url,
283
+ command: info.command,
284
+ args: [],
285
+ cwd: '',
286
+ envKeys: [],
287
+ headerKeys: [],
288
+ toolCallTimeoutMs: null,
289
+ failOnStartupError: false,
290
+ disabled: false,
291
+ inPatch: true,
292
+ /** In the document, not loaded by this process yet. */
293
+ mounted: false,
294
+ editable: {},
295
+ })
296
+ }
297
+
298
+ for (const server of servers) {
299
+ if (!placed.has(server.patchId)) merged.push({ ...server, mounted: true })
300
+ }
301
+
302
+ return { merged, servers, error }
303
+ }
304
+
305
+ /** `mcp__<server>__<tool>` grouped by server, with descriptions only. */
306
+ function readToolGroups(ctx) {
307
+ let schemas = []
308
+ try {
309
+ schemas = ctx.tools.schemas() || []
310
+ } catch {
311
+ return new Map()
312
+ }
313
+
314
+ const groups = new Map()
315
+ for (const schema of schemas) {
316
+ const name = text(schema && schema.name)
317
+ if (!name.startsWith('mcp__')) continue
318
+ const body = name.slice('mcp__'.length)
319
+ const cut = body.indexOf('__')
320
+ if (cut <= 0) continue
321
+ const server = body.slice(0, cut)
322
+ if (!groups.has(server)) groups.set(server, [])
323
+ groups.get(server).push({
324
+ name,
325
+ wire: body.slice(cut + 2),
326
+ description: clip(schema.description, 240),
327
+ })
328
+ }
329
+
330
+ for (const list of groups.values()) list.sort((a, b) => a.wire.localeCompare(b.wire))
331
+ return groups
332
+ }
333
+
334
+ function sanitizePatch(input) {
335
+ const clean = {}
336
+ for (const key of Object.keys(asObject(input))) {
337
+ if (!EDITABLE.has(key)) continue
338
+ const value = input[key]
339
+ if (key === 'args' && Array.isArray(value)) {
340
+ clean.args = value.map((item) => String(item))
341
+ continue
342
+ }
343
+ if ((key === 'env' || key === 'headers') && value && typeof value === 'object') {
344
+ const map = {}
345
+ for (const entry of Object.entries(value)) {
346
+ if (typeof entry[1] === 'string') map[entry[0]] = entry[1]
347
+ }
348
+ clean[key] = map
349
+ continue
350
+ }
351
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
352
+ clean[key] = value
353
+ }
354
+ }
355
+ return clean
356
+ }
357
+
358
+ /** Validate a new server before it reaches the document. */
359
+ function readNewServer(input) {
360
+ const config = asObject(input)
361
+ const serverName = text(config.serverName).trim()
362
+ const transport = text(config.transport).trim()
363
+ const out = { error: null, config: null }
364
+
365
+ if (!SERVER_NAME.test(serverName)) {
366
+ out.error = 'serverName 只能包含字母、数字、下划线和短横线,长度 1–32'
367
+ return out
368
+ }
369
+ if (transport !== 'stdio' && transport !== 'streamable-http') {
370
+ out.error = 'transport 只能是 stdio 或 streamable-http'
371
+ return out
372
+ }
373
+
374
+ const next = { serverName, transport }
375
+ if (transport === 'streamable-http') {
376
+ const url = text(config.url).trim()
377
+ if (!url) {
378
+ out.error = 'streamable-http 需要 url'
379
+ return out
380
+ }
381
+ next.url = url
382
+ const headers = asObject(config.headers)
383
+ const names = Object.keys(headers)
384
+ if (names.length > 0) {
385
+ next.headers = {}
386
+ for (const name of names) {
387
+ if (!HEADER_NAME.test(name)) {
388
+ out.error = `请求头名不合法:${name}`
389
+ return out
390
+ }
391
+ if (typeof headers[name] === 'string') next.headers[name] = headers[name]
392
+ }
393
+ }
394
+ } else {
395
+ const command = text(config.command).trim()
396
+ if (!command) {
397
+ out.error = 'stdio 需要 command'
398
+ return out
399
+ }
400
+ next.command = command
401
+ next.args = Array.isArray(config.args) ? config.args.map((item) => String(item)).filter(Boolean) : []
402
+ const env = asObject(config.env)
403
+ const keys = Object.keys(env)
404
+ if (keys.length > 0) {
405
+ next.env = {}
406
+ for (const key of keys) if (typeof env[key] === 'string') next.env[key] = env[key]
407
+ }
408
+ }
409
+
410
+ const timeout = config.toolCallTimeoutMs
411
+ if (timeout !== undefined && timeout !== null && timeout !== '') {
412
+ const ms = Number(timeout)
413
+ if (!Number.isFinite(ms) || ms <= 0) {
414
+ out.error = 'toolCallTimeoutMs 必须是正数毫秒'
415
+ return out
416
+ }
417
+ next.toolCallTimeoutMs = ms
418
+ }
419
+ if (config.failOnStartupError === true) next.failOnStartupError = true
420
+
421
+ out.config = next
422
+ return out
423
+ }
424
+
425
+ function uniqueRowId(taken, serverName) {
426
+ const base = `mcp-${serverName.replace(/[^A-Za-z0-9_-]/g, '-')}`
427
+ if (!taken.has(base)) return base
428
+ let index = 2
429
+ while (taken.has(`${base}-${index}`)) index += 1
430
+ return `${base}-${index}`
431
+ }
432
+
433
+ const PROBE_TIMEOUT_MS = 3000
434
+
435
+ /** TCP reachability of an http(s) endpoint; no protocol payload. */
436
+ function probeTcp(url, timeoutMs) {
437
+ const started = Date.now()
438
+ return new Promise((done) => {
439
+ let target
440
+ try {
441
+ target = new URL(url)
442
+ } catch {
443
+ done({ reachable: false, detail: `url 不是合法地址:${url}`, ms: 0 })
444
+ return
445
+ }
446
+ const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80))
447
+ const host = target.hostname
448
+ const socket = createConnection({ host, port })
449
+ let settled = false
450
+ const finish = (result) => {
451
+ if (settled) return
452
+ settled = true
453
+ socket.destroy()
454
+ done({ ...result, ms: Date.now() - started })
455
+ }
456
+ socket.setTimeout(timeoutMs, () => finish({ reachable: false, detail: `TCP ${host}:${port} 连接超时` }))
457
+ socket.on('connect', () => finish({ reachable: true, detail: `TCP ${host}:${port} 可连接` }))
458
+ socket.on('error', (error) => finish({ reachable: false, detail: `TCP ${host}:${port} 失败:${error.code || error.message}` }))
459
+ })
460
+ }
461
+
462
+ /** One MCP `initialize` POST, enough to tell "port open" from "not an MCP endpoint". */
463
+ async function probeHttp(url, timeoutMs) {
464
+ const controller = new AbortController()
465
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
466
+ const started = Date.now()
467
+ try {
468
+ const response = await fetch(url, {
469
+ method: 'POST',
470
+ headers: {
471
+ 'content-type': 'application/json',
472
+ accept: 'application/json, text/event-stream',
473
+ },
474
+ body: JSON.stringify({
475
+ jsonrpc: '2.0',
476
+ id: 1,
477
+ method: 'initialize',
478
+ params: {
479
+ protocolVersion: '2025-06-18',
480
+ capabilities: {},
481
+ clientInfo: { name: '@sunt2049/dsh-mcp-manager', version: '0.2.0' },
482
+ },
483
+ }),
484
+ signal: controller.signal,
485
+ })
486
+ const type = response.headers.get('content-type') || ''
487
+ const session = response.headers.get('mcp-session-id')
488
+ return {
489
+ reachable: true,
490
+ status: response.status,
491
+ contentType: type,
492
+ detail: `HTTP ${response.status}${type ? ' · ' + type.split(';')[0] : ''}${session ? ' · 有 session-id' : ''}`,
493
+ ms: Date.now() - started,
494
+ }
495
+ } catch (error) {
496
+ return {
497
+ reachable: false,
498
+ detail: `initialize POST 失败:${String((error && error.message) || error)}`,
499
+ ms: Date.now() - started,
500
+ }
501
+ } finally {
502
+ clearTimeout(timer)
503
+ }
504
+ }
505
+
506
+ /**
507
+ * A path the shell resolves itself; bare names (`cmd.exe`) come from PATH.
508
+ * A single leading slash is a cmd switch (`/d`, `/c`), not a path.
509
+ */
510
+ function isAbsolutePath(value) {
511
+ if (/^[A-Za-z]:[\\/]/.test(value)) return true
512
+ if (value.startsWith('\\\\') || value.startsWith('//')) return true
513
+ if (value.startsWith('/')) return value.length > 3 && value.slice(1).includes('/')
514
+ return false
515
+ }
516
+
517
+ /** Explain a stdio row without spawning it. */
518
+ function probeStdio(server) {
519
+ if (!server.command) return { reachable: false, detail: '没有配置 command', ms: 0 }
520
+
521
+ const findings = []
522
+ let ok = true
523
+
524
+ if (isAbsolutePath(server.command)) {
525
+ if (existsSync(server.command)) {
526
+ findings.push('command 存在')
527
+ } else {
528
+ ok = false
529
+ findings.push(`command 不存在:${server.command}`)
530
+ }
531
+ } else {
532
+ // `/d`, `/c`, `-y` and friends are switches, not files: only absolute
533
+ // arguments are checked, and a bare command name is resolved by PATH.
534
+ findings.push(`command 由 PATH 解析:${server.command}`)
535
+ }
536
+
537
+ const paths = (server.args || []).filter((arg) => isAbsolutePath(arg))
538
+ for (const arg of paths) {
539
+ if (!existsSync(arg)) {
540
+ ok = false
541
+ findings.push(`参数文件缺失:${arg}`)
542
+ }
543
+ }
544
+ if (paths.length === 0) findings.push('参数里没有绝对路径文件')
545
+
546
+ return { reachable: ok, detail: findings.join(' · '), ms: 0 }
547
+ }
548
+
549
+ export function apply(ctx) {
550
+ ctx.inject(['webServer', 'tools', 'configEditor'], (hostCtx) => {
551
+ const routes = []
552
+
553
+ routes.push(hostCtx.webServer.register({
554
+ kind: 'exact',
555
+ path: '/mcp-manager/overview',
556
+ handler: async (request, response) => {
557
+ if (request.method !== 'GET') {
558
+ response.writeHead(405, { allow: 'GET' })
559
+ response.end()
560
+ return
561
+ }
562
+ try {
563
+ const patchPath = hostCtx.configEditor.documentPath || ''
564
+ const { merged, servers, error } = readAllServers(hostCtx, patchPath)
565
+ const groups = readToolGroups(hostCtx)
566
+ const parked = readParked(patchPath)
567
+ const parkedIds = new Set(Object.keys(parked))
568
+
569
+ // A parked row is still mounted until the host restarts, but the user
570
+ // already said "off": keep it out of the active list and let the parked
571
+ // entry report that the running process still has it.
572
+ const active = merged.filter((server) => !parkedIds.has(server.patchId))
573
+ const withTools = active.map((server) => ({
574
+ ...server,
575
+ tools: groups.get(server.serverName) || [],
576
+ }))
577
+ const toolTotal = withTools.reduce((total, server) => total + server.tools.length, 0)
578
+
579
+ const mounted = new Set(servers.map((server) => server.patchId))
580
+ const parkedList = Object.entries(parked)
581
+ .map(([id, record]) => ({
582
+ id,
583
+ ...describeRow(record && record.lines),
584
+ savedAt: text(record && record.savedAt),
585
+ /** Still loaded in this process; a restart is what actually drops it. */
586
+ stillMounted: mounted.has(id),
587
+ }))
588
+ .sort((a, b) => a.id.localeCompare(b.id))
589
+
590
+ sendJson(response, 200, {
591
+ ok: true,
592
+ patchPath,
593
+ error,
594
+ servers: withTools,
595
+ parked: parkedList,
596
+ toolTotal,
597
+ serverTotal: withTools.length,
598
+ /** True only while a document row has not been applied yet. */
599
+ pendingRestart: withTools.some((server) => server.mounted === false),
600
+ rowsTouchedAt,
601
+ generatedAt: new Date().toISOString(),
602
+ })
603
+ } catch (error) {
604
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
605
+ }
606
+ },
607
+ }))
608
+
609
+ routes.push(hostCtx.webServer.register({
610
+ kind: 'exact',
611
+ path: '/mcp-manager/schema',
612
+ handler: async (request, response) => {
613
+ if (request.method !== 'GET') {
614
+ response.writeHead(405, { allow: 'GET' })
615
+ response.end()
616
+ return
617
+ }
618
+ try {
619
+ const url = new URL(request.url || '/', 'http://localhost')
620
+ const wanted = text(url.searchParams.get('tool'))
621
+ if (!wanted) {
622
+ sendJson(response, 400, { ok: false, error: 'tool is required' })
623
+ return
624
+ }
625
+ let schemas = []
626
+ try {
627
+ schemas = hostCtx.tools.schemas() || []
628
+ } catch {
629
+ schemas = []
630
+ }
631
+ const found = schemas.find((schema) => schema && schema.name === wanted)
632
+ if (!found) {
633
+ sendJson(response, 404, { ok: false, error: 'tool not found in the live registry' })
634
+ return
635
+ }
636
+ sendJson(response, 200, {
637
+ ok: true,
638
+ name: found.name,
639
+ description: found.description || '',
640
+ parameters: found.parameters || {},
641
+ })
642
+ } catch (error) {
643
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
644
+ }
645
+ },
646
+ }))
647
+
648
+ routes.push(hostCtx.webServer.register({
649
+ kind: 'exact',
650
+ path: '/mcp-manager/export',
651
+ handler: async (request, response) => {
652
+ if (request.method !== 'GET') {
653
+ response.writeHead(405, { allow: 'GET' })
654
+ response.end()
655
+ return
656
+ }
657
+ try {
658
+ const patchPath = hostCtx.configEditor.documentPath || ''
659
+ const { merged } = readAllServers(hostCtx, patchPath)
660
+ sendJson(response, 200, {
661
+ ok: true,
662
+ patchPath,
663
+ generatedAt: new Date().toISOString(),
664
+ parked: readParked(patchPath),
665
+ servers: merged.map((server) => ({
666
+ id: server.id,
667
+ mounted: server.mounted,
668
+ inPatch: server.inPatch,
669
+ config: server.editable,
670
+ })),
671
+ })
672
+ } catch (error) {
673
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
674
+ }
675
+ },
676
+ }))
677
+
678
+ routes.push(hostCtx.webServer.register({
679
+ kind: 'exact',
680
+ path: '/mcp-manager/save',
681
+ handler: async (request, response) => {
682
+ if (request.method !== 'POST') {
683
+ response.writeHead(405, { allow: 'POST' })
684
+ response.end()
685
+ return
686
+ }
687
+ if (!trustedOrigin(request)) {
688
+ sendJson(response, 403, { ok: false, error: 'untrusted origin' })
689
+ return
690
+ }
691
+ try {
692
+ const body = await readBody(request)
693
+ const id = text(body && body.id)
694
+ if (!id) {
695
+ sendJson(response, 400, { ok: false, error: 'id is required' })
696
+ return
697
+ }
698
+ const clean = sanitizePatch(body && body.patch)
699
+ if (Object.keys(clean).length === 0) {
700
+ sendJson(response, 400, { ok: false, error: 'patch carries no editable field' })
701
+ return
702
+ }
703
+ const entry = (hostCtx.configEditor.entries() || []).find((row) => row && row.id === id)
704
+ if (!entry) {
705
+ sendJson(response, 404, { ok: false, error: `no active entry with id ${id}` })
706
+ return
707
+ }
708
+ await hostCtx.configEditor.edit(entry, (current) => {
709
+ const next = { ...asObject(current) }
710
+ for (const key of Object.keys(clean)) {
711
+ if (key === 'headers') continue
712
+ next[key] = clean[key]
713
+ }
714
+ // The panel never reads header values back, so a header patch is a
715
+ // set of writes and deletes merged over what the row already has.
716
+ // An empty string deletes that header.
717
+ if (clean.headers) {
718
+ const merged = { ...asObject(next.headers) }
719
+ for (const [name, value] of Object.entries(clean.headers)) {
720
+ if (value === '') delete merged[name]
721
+ else merged[name] = value
722
+ }
723
+ next.headers = merged
724
+ }
725
+ return next
726
+ })
727
+ sendJson(response, 200, { ok: true, id, applied: Object.keys(clean), live: true })
728
+ } catch (error) {
729
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
730
+ }
731
+ },
732
+ }))
733
+
734
+ routes.push(hostCtx.webServer.register({
735
+ kind: 'exact',
736
+ path: '/mcp-manager/rows',
737
+ handler: async (request, response) => {
738
+ if (request.method !== 'POST') {
739
+ response.writeHead(405, { allow: 'POST' })
740
+ response.end()
741
+ return
742
+ }
743
+ if (!trustedOrigin(request)) {
744
+ sendJson(response, 403, { ok: false, error: 'untrusted origin' })
745
+ return
746
+ }
747
+ try {
748
+ const body = await readBody(request)
749
+ const action = text(body && body.action)
750
+ const patchPath = hostCtx.configEditor.documentPath || ''
751
+ if (!patchPath) {
752
+ sendJson(response, 500, { ok: false, error: 'profile patch path is unknown' })
753
+ return
754
+ }
755
+
756
+ if (action === 'add') {
757
+ const { error, config } = readNewServer(body && body.server)
758
+ if (error) {
759
+ sendJson(response, 400, { ok: false, error })
760
+ return
761
+ }
762
+ const { blocks, lossless } = readDocument(patchPath)
763
+ if (!lossless) {
764
+ sendJson(response, 409, {
765
+ ok: false,
766
+ error: 'patch 文件无法被无损解析,为避免破坏注释已放弃写入',
767
+ })
768
+ return
769
+ }
770
+ const id = uniqueRowId(new Set(rowIds(blocks)), config.serverName)
771
+ writeDocument(patchPath, appendRow(blocks, buildRowLines(id, config)))
772
+ rowsTouchedAt = new Date().toISOString()
773
+ sendJson(response, 200, { ok: true, action, id, patchPath, pendingRestart: true })
774
+ return
775
+ }
776
+
777
+ const id = text(body && body.id)
778
+ if (!id) {
779
+ sendJson(response, 400, { ok: false, error: 'id is required' })
780
+ return
781
+ }
782
+
783
+ const parked = readParked(patchPath)
784
+
785
+ if (action === 'forget') {
786
+ if (!parked[id]) {
787
+ sendJson(response, 404, { ok: false, error: `没有找到已停用的 ${id}` })
788
+ return
789
+ }
790
+ delete parked[id]
791
+ writeParked(patchPath, parked)
792
+ sendJson(response, 200, { ok: true, action, id, patchPath })
793
+ return
794
+ }
795
+
796
+ if (action === 'enable') {
797
+ const record = parked[id]
798
+ if (!record || !Array.isArray(record.lines)) {
799
+ sendJson(response, 404, { ok: false, error: `没有找到已停用的 ${id}` })
800
+ return
801
+ }
802
+ const { blocks, lossless } = readDocument(patchPath)
803
+ if (!lossless) {
804
+ sendJson(response, 409, { ok: false, error: 'patch 文件无法被无损解析,已放弃写入' })
805
+ return
806
+ }
807
+ if (rowIds(blocks).includes(id)) {
808
+ delete parked[id]
809
+ writeParked(patchPath, parked)
810
+ sendJson(response, 409, { ok: false, error: `${id} 已经在 patch 里了` })
811
+ return
812
+ }
813
+ writeDocument(
814
+ patchPath,
815
+ insertRow(blocks, record.lines, { beforeId: record.beforeId, afterId: record.afterId }),
816
+ )
817
+ delete parked[id]
818
+ writeParked(patchPath, parked)
819
+ rowsTouchedAt = new Date().toISOString()
820
+ sendJson(response, 200, { ok: true, action, id, patchPath, pendingRestart: true })
821
+ return
822
+ }
823
+
824
+ if (action === 'remove' || action === 'disable') {
825
+ const { blocks, lossless } = readDocument(patchPath)
826
+ if (!lossless) {
827
+ sendJson(response, 409, { ok: false, error: 'patch 文件无法被无损解析,已放弃写入' })
828
+ return
829
+ }
830
+ const { blocks: next, removed, beforeId, afterId } = removeRow(blocks, id)
831
+ if (!removed) {
832
+ sendJson(response, 404, {
833
+ ok: false,
834
+ error: `${id} 不在这份 patch 里(可能来自其它层),无法在此删除`,
835
+ })
836
+ return
837
+ }
838
+ writeDocument(patchPath, next)
839
+ if (action === 'disable') {
840
+ parked[id] = {
841
+ lines: removed.split('\n'),
842
+ savedAt: new Date().toISOString(),
843
+ // Remember the neighbours so "启用" can put the row back where
844
+ // it was instead of appending it to the end of the document.
845
+ beforeId: beforeId || null,
846
+ afterId: afterId || null,
847
+ }
848
+ writeParked(patchPath, parked)
849
+ } else if (parked[id]) {
850
+ delete parked[id]
851
+ writeParked(patchPath, parked)
852
+ }
853
+ rowsTouchedAt = new Date().toISOString()
854
+ sendJson(response, 200, { ok: true, action, id, patchPath, pendingRestart: true })
855
+ return
856
+ }
857
+
858
+ sendJson(response, 400, { ok: false, error: `unknown action: ${action}` })
859
+ } catch (error) {
860
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
861
+ }
862
+ },
863
+ }))
864
+
865
+ routes.push(hostCtx.webServer.register({
866
+ kind: 'exact',
867
+ path: '/mcp-manager/probe',
868
+ handler: async (request, response) => {
869
+ if (request.method !== 'POST') {
870
+ response.writeHead(405, { allow: 'POST' })
871
+ response.end()
872
+ return
873
+ }
874
+ if (!trustedOrigin(request)) {
875
+ sendJson(response, 403, { ok: false, error: 'untrusted origin' })
876
+ return
877
+ }
878
+ try {
879
+ const body = await readBody(request)
880
+ const id = text(body && body.id)
881
+ const patchPath = hostCtx.configEditor.documentPath || ''
882
+ // Same view the list uses: a row enabled a moment ago is in the
883
+ // document but not yet reported by `configuration()`, and its id is
884
+ // still the patch id rather than the loader entry id.
885
+ const { merged } = readAllServers(hostCtx, patchPath)
886
+ const server = merged.find(
887
+ (row) => row.id === id || row.patchId === id || row.serverName === id,
888
+ )
889
+ if (!server) {
890
+ sendJson(response, 404, { ok: false, error: `找不到服务器 ${id}` })
891
+ return
892
+ }
893
+ const pendingNote = server.mounted === false ? '(尚未挂载,按 patch 里的配置判断) · ' : ''
894
+
895
+ if (server.transport === 'streamable-http') {
896
+ if (!server.url) {
897
+ sendJson(response, 200, { ok: true, serverName: server.serverName, verdict: 'bad-config', detail: '没有配置 url', ms: 0 })
898
+ return
899
+ }
900
+ const tcp = await probeTcp(server.url, PROBE_TIMEOUT_MS)
901
+ if (!tcp.reachable) {
902
+ sendJson(response, 200, {
903
+ ok: true,
904
+ serverName: server.serverName,
905
+ verdict: 'port-closed',
906
+ detail: pendingNote + tcp.detail,
907
+ ms: tcp.ms,
908
+ })
909
+ return
910
+ }
911
+ const http = await probeHttp(server.url, PROBE_TIMEOUT_MS)
912
+ sendJson(response, 200, {
913
+ ok: true,
914
+ serverName: server.serverName,
915
+ verdict: http.reachable ? 'handshake-ok' : 'handshake-failed',
916
+ detail: pendingNote + http.detail,
917
+ status: http.status ?? null,
918
+ contentType: http.contentType ?? null,
919
+ tcp: tcp.detail,
920
+ ms: tcp.ms + (http.ms || 0),
921
+ })
922
+ return
923
+ }
924
+
925
+ if (server.transport === 'stdio') {
926
+ const stdio = probeStdio(server)
927
+ sendJson(response, 200, {
928
+ ok: true,
929
+ serverName: server.serverName,
930
+ verdict: stdio.reachable ? 'files-ok' : 'files-missing',
931
+ detail: pendingNote + stdio.detail,
932
+ ms: 0,
933
+ })
934
+ return
935
+ }
936
+
937
+ sendJson(response, 200, {
938
+ ok: true,
939
+ serverName: server.serverName,
940
+ verdict: 'unknown',
941
+ detail: `不支持的传输方式:${server.transport || '(空)'}`,
942
+ ms: 0,
943
+ })
944
+ } catch (error) {
945
+ sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
946
+ }
947
+ },
948
+ }))
949
+
950
+ return () => {
951
+ for (const dispose of routes) {
952
+ try {
953
+ dispose()
954
+ } catch {
955
+ /* the carrier is already gone */
956
+ }
957
+ }
958
+ }
959
+ })
960
+ }