@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/LICENSE +21 -0
- package/README.md +57 -2
- package/client.js +1289 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +8 -0
- package/index.js +960 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
- package/patch-doc.js +287 -0
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
|
+
}
|