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