@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.1
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 +407 -2
- package/README.zh.md +225 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/adapters.js +346 -0
- package/lib/canonical.js +488 -0
- package/lib/client.js +1151 -0
- package/lib/dsh-patch.js +329 -0
- package/lib/dsh.js +259 -0
- package/lib/index.js +495 -0
- package/lib/ops.js +325 -0
- package/lib/probe.js +257 -0
- package/lib/scan.js +353 -0
- package/lib/store.js +257 -0
- package/lib/surgical.js +581 -0
- package/lib/toggle.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -4
package/lib/ops.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutations: the operations the UI buttons and the agent tool both call.
|
|
3
|
+
*
|
|
4
|
+
* Both callers go through these exact functions (see the plugin reference on
|
|
5
|
+
* user actions and agent tools): one implementation, two entry points, so the
|
|
6
|
+
* tool's result and the UI's result cannot drift apart.
|
|
7
|
+
*
|
|
8
|
+
* Every write is a guarded compare-and-set against the file hash the caller
|
|
9
|
+
* read (see store.mutate). Project-scope targets additionally refuse literal
|
|
10
|
+
* credentials, because those files are committed to repositories.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CLIENT_BY_ID, resolveClientPaths } from './adapters.js';
|
|
14
|
+
import { decodeEntry, definitionHash, dshMountName, encodeEntry, isSecretKey, isSecretValue, isMaskedValue, isReferenceValue, secretArgs, sameDefinition, sanitizeName, uniqueName } from './canonical.js';
|
|
15
|
+
import { readContainer, removeContainerEntry, writeContainerEntry, readTomlServers, writeTomlServer, removeTomlServer } from './surgical.js';
|
|
16
|
+
import { dshSnippet, mcpEntries, upsertDshServer, removeDshServer } from './dsh.js';
|
|
17
|
+
import { writeDshPatchServer, removeDshPatchServer } from './dsh-patch.js';
|
|
18
|
+
|
|
19
|
+
const WRITABLE_KINDS = new Set(['json', 'toml']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Does this server carry a credential literal that must not be committed?
|
|
23
|
+
*
|
|
24
|
+
* Key-driven, matching the masking policy: a project-scope file must not
|
|
25
|
+
* receive a plaintext credential even when the value is short. Arguments are
|
|
26
|
+
* checked too — `npx some-mcp --api-key=…` is a real invocation shape.
|
|
27
|
+
*/
|
|
28
|
+
export function hasLiteralSecret(server) {
|
|
29
|
+
for (const map of [server.env, server.headers]) {
|
|
30
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
31
|
+
if (isSecretKey(key) && !isReferenceValue(value) && String(value ?? '').length > 0) {
|
|
32
|
+
return { key, where: map === server.env ? 'env' : 'header' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const found of secretArgs(server.args)) {
|
|
37
|
+
if (!isReferenceValue(String(found.value))) return { key: found.key, where: 'args' };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Does this server carry a redaction placeholder anywhere a credential lives?
|
|
44
|
+
*
|
|
45
|
+
* Checked separately from `hasLiteralSecret` because the two failures want
|
|
46
|
+
* different messages: a literal is a policy refusal, while a mask means the
|
|
47
|
+
* caller sent back the browser's redacted copy and the real value is missing.
|
|
48
|
+
*/
|
|
49
|
+
export function maskedField(server) {
|
|
50
|
+
for (const [where, map] of [['env', server.env], ['header', server.headers]]) {
|
|
51
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
52
|
+
if (isMaskedValue(value)) return { key, where };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const found of secretArgs(server.args)) {
|
|
56
|
+
if (isMaskedValue(String(found.value))) return { key: found.key, where: 'args' };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the file text that would result from placing `server` at `target`.
|
|
63
|
+
* Pure: same inputs, same bytes. Used for both the dry-run preview and the
|
|
64
|
+
* actual write so the preview is never a lie.
|
|
65
|
+
*/
|
|
66
|
+
export function renderTargetText({ client, text, server, existingName }) {
|
|
67
|
+
const name = existingName ?? server.name;
|
|
68
|
+
const entry = encodeEntry({ ...server, name }, client.dialect);
|
|
69
|
+
if (client.format === 'toml') return writeTomlServer(text, name, entry);
|
|
70
|
+
const written = writeContainerEntry(text, client.containerKey, name, entry);
|
|
71
|
+
if (!written.ok) throw new Error(written.error);
|
|
72
|
+
return written.text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pick the concrete file a `{clientId, scope}` pair maps to. */
|
|
76
|
+
export function locateTarget({ clientId, scope, path, projectRoots = [] }) {
|
|
77
|
+
const client = CLIENT_BY_ID.get(clientId);
|
|
78
|
+
if (!client) return { ok: false, code: 'error.target.unknownClient', error: `unknown client ${clientId}` };
|
|
79
|
+
if (!WRITABLE_KINDS.has(client.format) && !client.managed) {
|
|
80
|
+
return { ok: false, code: 'error.target.readOnly', error: `${clientId} is read-only` };
|
|
81
|
+
}
|
|
82
|
+
if (client.readOnly && !client.managed) {
|
|
83
|
+
return { ok: false, code: 'error.target.readOnly', error: `${clientId} is read-only here` };
|
|
84
|
+
}
|
|
85
|
+
if (typeof path === 'string' && path) return { ok: true, client, scope: scope ?? 'global', path };
|
|
86
|
+
/*
|
|
87
|
+
* A managed client (DSH) has no file of its own: its rows live in the profile
|
|
88
|
+
* composition and are addressed through configEditor or the patch layer, so
|
|
89
|
+
* there is a single logical location and no path to resolve.
|
|
90
|
+
*/
|
|
91
|
+
if (client.managed) {
|
|
92
|
+
const wanted = scope ?? 'profile';
|
|
93
|
+
if (wanted !== 'profile') return { ok: false, code: 'error.target.noScope', error: `${clientId} only has profile scope` };
|
|
94
|
+
return { ok: true, client, scope: 'profile', path: '(profile composition)' };
|
|
95
|
+
}
|
|
96
|
+
const candidates = resolveClientPaths(client, projectRoots[0]);
|
|
97
|
+
const match = candidates.find((item) => item.scope === (scope ?? 'global'));
|
|
98
|
+
if (!match) return { ok: false, code: 'error.target.noScope', error: `no ${scope} location for ${clientId}` };
|
|
99
|
+
return { ok: true, client, scope: match.scope, path: match.path };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Write one server into one target.
|
|
104
|
+
*
|
|
105
|
+
* `expectedHash` is required for a safe write: the caller passes the hash it
|
|
106
|
+
* scanned, and a changed file returns 409 instead of clobbering the user's edit.
|
|
107
|
+
*/
|
|
108
|
+
export async function applyServerToTarget({ store, configEditor, profileDir }, input) {
|
|
109
|
+
const located = locateTarget(input);
|
|
110
|
+
if (!located.ok) return located;
|
|
111
|
+
const { client, scope, path } = located;
|
|
112
|
+
const server = input.server;
|
|
113
|
+
if (!server || typeof server !== 'object') return { ok: false, code: 'error.input.server', error: 'server is required' };
|
|
114
|
+
|
|
115
|
+
const secret = hasLiteralSecret(server);
|
|
116
|
+
if (secret && scope === 'project') {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
code: 'error.secret.projectScope',
|
|
120
|
+
error: `refusing to write literal ${secret.where} credential ${secret.key} into a project-scope file`,
|
|
121
|
+
hint: 'replace the value with a variable reference, or target the global scope',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* A masked definition means the caller handed back the redacted copy the
|
|
127
|
+
* browser holds. Writing it would replace the credential with `••••`, so
|
|
128
|
+
* refuse loudly instead of corrupting a working config.
|
|
129
|
+
*/
|
|
130
|
+
const masked = maskedField(server);
|
|
131
|
+
if (masked) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
code: 'error.server.masked',
|
|
135
|
+
error: `refusing to write a masked ${masked.where} value for ${masked.key}`,
|
|
136
|
+
hint: 'the definition must come from the host; reload the state and retry',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (client.managed) {
|
|
141
|
+
const rows = mcpEntries(configEditor);
|
|
142
|
+
/*
|
|
143
|
+
* Mount under the name the harness can accept, not the display name: a CJK
|
|
144
|
+
* or spaced name would otherwise be sanitized to a colliding stub. See
|
|
145
|
+
* `dshMountName` for the current value of that mapping and what going
|
|
146
|
+
* through the raw display name broke.
|
|
147
|
+
*/
|
|
148
|
+
const targetName = dshMountName(server.serverName ?? server.name);
|
|
149
|
+
const previous = rows.find((row) => {
|
|
150
|
+
const config = { ...row.inherited, ...row.override };
|
|
151
|
+
return config.serverName === targetName;
|
|
152
|
+
}) ?? (input.rowId ? rows.find((row) => row.entry?.id === input.rowId) : undefined);
|
|
153
|
+
/*
|
|
154
|
+
* Two ways a DSH row can come into being, and the right one depends on
|
|
155
|
+
* whether the row already exists: configEditor rewrites a live entry
|
|
156
|
+
* (validated, hot-reloaded), while a brand-new server has no entry to
|
|
157
|
+
* address and must go through the patch layer. Try the row first so the
|
|
158
|
+
* normal case keeps using the validated path.
|
|
159
|
+
*/
|
|
160
|
+
if (previous) {
|
|
161
|
+
const result = await upsertDshServer(configEditor, { server: { ...server, serverName: targetName }, previous });
|
|
162
|
+
if (result.ok) store.recordOwnership({ serverName: targetName, displayName: server.serverName ?? server.name, clientId: 'dsh', path: '(profile composition)', scope: 'profile', fingerprint: definitionHash(server) });
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
const patched = writeDshPatchServer({ server: { ...server, serverName: targetName }, store, profileDir });
|
|
166
|
+
if (patched.ok) {
|
|
167
|
+
store.recordOwnership({ serverName: targetName, displayName: server.serverName ?? server.name, clientId: 'dsh', path: '(profile composition)', scope: 'profile', fingerprint: definitionHash(server) });
|
|
168
|
+
/*
|
|
169
|
+
* The patch layer is only read when the Loader reconciles, so unlike an
|
|
170
|
+
* edit this needs a restart. Say so rather than implying it is live.
|
|
171
|
+
*/
|
|
172
|
+
return { ...patched, restartRequired: patched.changed === true, via: 'patch' };
|
|
173
|
+
}
|
|
174
|
+
return patched;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Name collision inside the target file.
|
|
178
|
+
const current = store.readFile(path);
|
|
179
|
+
if (!current.ok) return { ok: false, code: 'error.file.read', error: current.error };
|
|
180
|
+
const existing = new Map();
|
|
181
|
+
try {
|
|
182
|
+
if (client.format === 'toml') {
|
|
183
|
+
for (const [key, value] of Object.entries(readTomlServers(current.text))) existing.set(key, value);
|
|
184
|
+
} else {
|
|
185
|
+
const container = readContainer(current.text, client.containerKey);
|
|
186
|
+
if (!container.ok) return { ok: false, code: 'error.file.parse', error: container.error };
|
|
187
|
+
for (const [key, value] of Object.entries(container.value ?? {})) existing.set(key, value);
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { ok: false, code: 'error.file.parse', error: String(error && error.message ? error.message : error) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/*
|
|
194
|
+
* Re-writing a server we already placed must be an in-place update, not a
|
|
195
|
+
* second copy under a suffixed name. So a taken name is still ours to
|
|
196
|
+
* overwrite when either the definition on disk already equals the one we are
|
|
197
|
+
* writing (a repeat apply) or the ledger says we wrote that entry and the
|
|
198
|
+
* user has not edited it since. Only a foreign server sitting on the name
|
|
199
|
+
* gets the suffix treatment, so we never silently clobber someone else's.
|
|
200
|
+
*/
|
|
201
|
+
const wanted = sanitizeName(server.serverName ?? server.name);
|
|
202
|
+
const taken = new Set(existing.keys());
|
|
203
|
+
let finalName = wanted;
|
|
204
|
+
if (taken.has(wanted)) {
|
|
205
|
+
const onDisk = decodeEntry(wanted, existing.get(wanted), client.dialect);
|
|
206
|
+
const sameDefinitionAlready = sameDefinition(onDisk, server);
|
|
207
|
+
const owned = store.ownershipOf(wanted)?.targets?.[`${client.id}:${scope}:${path}`];
|
|
208
|
+
const ownedAndUnedited = owned && owned.fingerprint === definitionHash(onDisk);
|
|
209
|
+
if (!sameDefinitionAlready && !ownedAndUnedited) finalName = uniqueName(wanted, taken);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result = store.mutate({
|
|
213
|
+
path,
|
|
214
|
+
clientId: client.id,
|
|
215
|
+
scope,
|
|
216
|
+
serverName: finalName,
|
|
217
|
+
expectedHash: input.expectedHash,
|
|
218
|
+
force: input.force === true,
|
|
219
|
+
label: 'apply',
|
|
220
|
+
mutate: (text) => {
|
|
221
|
+
const nextText = renderTargetText({ client, text, server: { ...server, name: finalName }, existingName: finalName });
|
|
222
|
+
return { text: nextText };
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
if (!result.ok) return result;
|
|
226
|
+
store.recordOwnership({ serverName: finalName, clientId: client.id, path, scope, fingerprint: definitionHash(server) });
|
|
227
|
+
return { ...result, name: finalName, clientId: client.id, scope };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Remove one server entry from one target. */
|
|
231
|
+
export function removeServerFromTarget({ store, configEditor, profileDir }, input) {
|
|
232
|
+
const located = locateTarget(input);
|
|
233
|
+
if (!located.ok) return located;
|
|
234
|
+
const { client, scope, path } = located;
|
|
235
|
+
const name = input.name;
|
|
236
|
+
if (!name) return { ok: false, code: 'error.input.name', error: 'name is required' };
|
|
237
|
+
if (client.managed) return { ok: false, code: 'error.dsh.useAsync', error: 'DSH removal needs the async path' };
|
|
238
|
+
const result = store.mutate({
|
|
239
|
+
path,
|
|
240
|
+
clientId: client.id,
|
|
241
|
+
scope,
|
|
242
|
+
serverName: name,
|
|
243
|
+
expectedHash: input.expectedHash,
|
|
244
|
+
force: input.force === true,
|
|
245
|
+
label: 'remove',
|
|
246
|
+
mutate: (text) => {
|
|
247
|
+
if (client.format === 'toml') return { text: removeTomlServer(text, name) };
|
|
248
|
+
const removed = removeContainerEntry(text, client.containerKey, name);
|
|
249
|
+
if (!removed.ok) throw new Error(removed.error);
|
|
250
|
+
return { text: removed.text };
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (result.ok && result.changed) store.forgetOwnership({ serverName: name, clientId: client.id, scope, path });
|
|
254
|
+
return { ...result, clientId: client.id, scope, name };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Async wrapper so DSH (configEditor) and file targets share one entry point. */
|
|
258
|
+
export async function removeServer(deps, input) {
|
|
259
|
+
const located = locateTarget(input);
|
|
260
|
+
if (located.ok && located.client.managed) {
|
|
261
|
+
/*
|
|
262
|
+
* The row is keyed by mount name, so a CJK display name addresses nothing
|
|
263
|
+
* directly. Map it exactly as the write path does, or the removal would
|
|
264
|
+
* report "missing" for a server that is plainly mounted.
|
|
265
|
+
*/
|
|
266
|
+
const rowName = input.mountName ? sanitizeName(input.mountName) : dshMountName(input.name);
|
|
267
|
+
const row = mcpEntries(deps.configEditor).find((candidate) => {
|
|
268
|
+
const config = { ...candidate.inherited, ...candidate.override };
|
|
269
|
+
return config.serverName === rowName;
|
|
270
|
+
});
|
|
271
|
+
/*
|
|
272
|
+
* A server may exist as a patch-layer insert (never reconciled into a live
|
|
273
|
+
* entry) or as a live row. Try both rather than reporting "missing" for one
|
|
274
|
+
* that is only in the patch layer.
|
|
275
|
+
*/
|
|
276
|
+
if (row) {
|
|
277
|
+
const result = await removeDshServer(deps.configEditor, row);
|
|
278
|
+
const patched = removeDshPatchServer({ serverName: rowName, store: deps.store, profileDir: deps.profileDir });
|
|
279
|
+
if (result.ok) deps.store.forgetOwnership({ serverName: rowName, clientId: 'dsh', scope: 'profile', path: '(profile composition)' });
|
|
280
|
+
return { ...result, ...(patched.changed ? { alsoRemovedFromPatch: true, restartRequired: true } : {}) };
|
|
281
|
+
}
|
|
282
|
+
const patched = removeDshPatchServer({ serverName: rowName, store: deps.store, profileDir: deps.profileDir });
|
|
283
|
+
if (patched.changed) {
|
|
284
|
+
deps.store.forgetOwnership({ serverName: rowName, clientId: 'dsh', scope: 'profile', path: '(profile composition)' });
|
|
285
|
+
return { ...patched, restartRequired: true, via: 'patch' };
|
|
286
|
+
}
|
|
287
|
+
return { ok: false, code: 'error.dsh.missing', error: `no MCP row named ${input.name}` };
|
|
288
|
+
}
|
|
289
|
+
return removeServerFromTarget(deps, input);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Copy one server definition to a set of targets, honoring each target's
|
|
294
|
+
* capabilities. Returns per-target results so a partial sync is visible.
|
|
295
|
+
*/
|
|
296
|
+
export async function syncServer(deps, { server, targets = [], expectedHashes = {}, force = false }) {
|
|
297
|
+
const results = [];
|
|
298
|
+
for (const target of targets) {
|
|
299
|
+
const located = locateTarget(target);
|
|
300
|
+
if (!located.ok) { results.push({ target, ...located }); continue; }
|
|
301
|
+
const dropped = [];
|
|
302
|
+
const shaped = { ...server };
|
|
303
|
+
for (const [field, supported] of Object.entries(located.client.caps ?? {})) {
|
|
304
|
+
if (supported) continue;
|
|
305
|
+
if (field === 'timeoutMs' && shaped.timeoutMs !== undefined) { delete shaped.timeoutMs; dropped.push('timeoutMs'); }
|
|
306
|
+
if (field === 'autoApprove' && shaped.autoApprove !== undefined) { shaped.autoApprove = []; dropped.push('autoApprove'); }
|
|
307
|
+
if (field === 'envFile') continue;
|
|
308
|
+
}
|
|
309
|
+
if (located.client.transportLimit && !located.client.transportLimit.includes(shaped.transport)) {
|
|
310
|
+
results.push({ target, ok: false, code: 'error.target.transport', error: `${located.client.id} cannot express ${shaped.transport}` });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const expectedHash = expectedHashes[`${target.clientId}:${target.scope}:${target.path}`] ?? expectedHashes[`${target.clientId}:${target.scope}`];
|
|
314
|
+
const result = await applyServerToTarget(deps, { ...target, server: shaped, expectedHash, force });
|
|
315
|
+
results.push({ target, ...result, droppedFields: dropped });
|
|
316
|
+
}
|
|
317
|
+
return { ok: results.every((item) => item.ok), results };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A YAML patch snippet for adding a brand-new DSH row. */
|
|
321
|
+
export function snippetFor(server) {
|
|
322
|
+
return dshSnippet(server);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export { decodeEntry };
|
package/lib/probe.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness probe: is a configured server actually reachable?
|
|
3
|
+
*
|
|
4
|
+
* Scanning config files tells you what is configured, not what works. A dead
|
|
5
|
+
* entry is indistinguishable from a live one on disk, and that is the single
|
|
6
|
+
* most common source of "my MCP tools vanished" reports. So each server gets a
|
|
7
|
+
* real handshake:
|
|
8
|
+
*
|
|
9
|
+
* - stdio spawn the command, write `initialize`, read the reply, then
|
|
10
|
+
* `tools/list`, then shut it down. This is the only way to learn
|
|
11
|
+
* whether the command exists, whether the package resolves, and
|
|
12
|
+
* whether the entry's env is complete.
|
|
13
|
+
* - remote POST `initialize` to the URL and accept either a JSON body or an
|
|
14
|
+
* event stream, then `tools/list` over the negotiated session.
|
|
15
|
+
*
|
|
16
|
+
* Everything is time-boxed and every failure is reported as data (code plus
|
|
17
|
+
* detail) rather than thrown, because a probe failing is normal, not an error.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
23
|
+
/*
|
|
24
|
+
* Sent to every server we handshake with, so a server's logs attribute the
|
|
25
|
+
* connection to this plugin and a version we still recognise. `test-release.mjs`
|
|
26
|
+
* asserts it matches package.json — a stale value here is otherwise invisible.
|
|
27
|
+
*/
|
|
28
|
+
const CLIENT_INFO = { name: 'dsh-mcp-manager', version: '0.1.1' };
|
|
29
|
+
|
|
30
|
+
function rpcRequest(id, method, params) {
|
|
31
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseJsonRpcLine(line) {
|
|
35
|
+
const text = line.trim();
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
if (text.startsWith('{')) {
|
|
38
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
if (text.startsWith('data:')) {
|
|
41
|
+
const body = text.slice(5).trim();
|
|
42
|
+
if (!body) return null;
|
|
43
|
+
try { return JSON.parse(body); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ------------------------------------------------------------------ stdio -- */
|
|
49
|
+
|
|
50
|
+
function probeStdio(server, timeoutMs) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
if (!server.command) {
|
|
53
|
+
resolve({ status: 'error', code: 'error.probe.noCommand', detail: 'stdio server has no command' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
let settled = false;
|
|
58
|
+
let buffer = '';
|
|
59
|
+
let tools = [];
|
|
60
|
+
let serverInfo = null;
|
|
61
|
+
let stderrTail = '';
|
|
62
|
+
let nextId = 1;
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(server.command, server.args ?? [], {
|
|
66
|
+
cwd: server.cwd || undefined,
|
|
67
|
+
env: { ...process.env, ...(server.env ?? {}) },
|
|
68
|
+
shell: process.platform === 'win32',
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
resolve({ status: 'error', code: 'error.probe.spawn', detail: String(error && error.message ? error.message : error) });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const finish = (result) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
82
|
+
resolve({ ...result, elapsedMs: Date.now() - started, serverInfo, toolCount: tools.length, tools: tools.slice(0, 50), stderrTail: stderrTail.slice(-800) });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
finish({ status: 'error', code: 'error.probe.timeout', detail: `no complete handshake within ${timeoutMs}ms` });
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
|
|
89
|
+
child.on('error', (error) => {
|
|
90
|
+
finish({ status: 'error', code: 'error.probe.spawn', detail: String(error && error.message ? error.message : error) });
|
|
91
|
+
});
|
|
92
|
+
child.on('exit', (code, signal) => {
|
|
93
|
+
if (!settled) {
|
|
94
|
+
finish({
|
|
95
|
+
status: 'error',
|
|
96
|
+
code: code === 0 ? 'error.probe.exited' : 'error.probe.exitCode',
|
|
97
|
+
detail: `process exited (code ${code ?? 'null'}, signal ${signal ?? 'none'}) before the handshake finished`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.stderr?.on('data', (chunk) => {
|
|
102
|
+
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4000);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const send = (id, method, params) => {
|
|
106
|
+
try {
|
|
107
|
+
child.stdin.write(`${rpcRequest(id, method, params)}\n`);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
finish({ status: 'error', code: 'error.probe.write', detail: String(error && error.message ? error.message : error) });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let stage = 'initialize';
|
|
114
|
+
child.stdout?.on('data', (chunk) => {
|
|
115
|
+
buffer += chunk.toString('utf8');
|
|
116
|
+
let newline = buffer.indexOf('\n');
|
|
117
|
+
while (newline >= 0) {
|
|
118
|
+
const line = buffer.slice(0, newline);
|
|
119
|
+
buffer = buffer.slice(newline + 1);
|
|
120
|
+
newline = buffer.indexOf('\n');
|
|
121
|
+
const message = parseJsonRpcLine(line);
|
|
122
|
+
if (!message) continue;
|
|
123
|
+
if (message.id === 1 && stage === 'initialize') {
|
|
124
|
+
if (message.error) {
|
|
125
|
+
finish({ status: 'error', code: 'error.probe.protocol', detail: String(message.error.message ?? 'initialize rejected') });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
serverInfo = message.result?.serverInfo ?? null;
|
|
129
|
+
stage = 'tools';
|
|
130
|
+
send(nextId += 1, 'notifications/initialized', undefined);
|
|
131
|
+
send(nextId += 1, 'tools/list', {});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (message.id === 3 && stage === 'tools') {
|
|
135
|
+
if (message.error) {
|
|
136
|
+
finish({ status: 'error', code: 'error.probe.protocol', detail: String(message.error.message ?? 'tools/list rejected') });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
tools = Array.isArray(message.result?.tools) ? message.result.tools : [];
|
|
140
|
+
finish({ status: 'ok', code: 'probe.ok' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
send(1, 'initialize', {
|
|
147
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
148
|
+
capabilities: {},
|
|
149
|
+
clientInfo: CLIENT_INFO,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ----------------------------------------------------------------- remote -- */
|
|
155
|
+
|
|
156
|
+
async function probeRemote(server, timeoutMs) {
|
|
157
|
+
if (!server.url) return { status: 'error', code: 'error.probe.noUrl', detail: 'remote server has no URL' };
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
161
|
+
const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(server.headers ?? {}) };
|
|
162
|
+
try {
|
|
163
|
+
const initResponse = await fetch(server.url, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
169
|
+
params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
if (!initResponse.ok) {
|
|
173
|
+
return { status: 'error', code: `error.probe.http${initResponse.status}`, detail: `${initResponse.status} ${initResponse.statusText}`.trim(), elapsedMs: Date.now() - started };
|
|
174
|
+
}
|
|
175
|
+
const sessionId = initResponse.headers.get('mcp-session-id') ?? undefined;
|
|
176
|
+
const initBody = await readRpcBody(initResponse);
|
|
177
|
+
if (initBody?.error) {
|
|
178
|
+
return { status: 'error', code: 'error.probe.protocol', detail: String(initBody.error.message ?? 'initialize rejected'), elapsedMs: Date.now() - started };
|
|
179
|
+
}
|
|
180
|
+
const serverInfo = initBody?.result?.serverInfo ?? null;
|
|
181
|
+
|
|
182
|
+
const nextHeaders = { ...headers };
|
|
183
|
+
if (sessionId) nextHeaders['mcp-session-id'] = sessionId;
|
|
184
|
+
// `notifications/initialized` is fire-and-forget; a failure here is not fatal.
|
|
185
|
+
fetch(server.url, {
|
|
186
|
+
method: 'POST', headers: nextHeaders, signal: controller.signal,
|
|
187
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
188
|
+
}).catch(() => undefined);
|
|
189
|
+
|
|
190
|
+
const listResponse = await fetch(server.url, {
|
|
191
|
+
method: 'POST', headers: nextHeaders, signal: controller.signal,
|
|
192
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
|
|
193
|
+
});
|
|
194
|
+
const listBody = await readRpcBody(listResponse);
|
|
195
|
+
const tools = Array.isArray(listBody?.result?.tools) ? listBody.result.tools : [];
|
|
196
|
+
if (!listResponse.ok) {
|
|
197
|
+
return { status: 'error', code: `error.probe.http${listResponse.status}`, detail: `${listResponse.status} ${listResponse.statusText}`.trim(), elapsedMs: Date.now() - started, serverInfo };
|
|
198
|
+
}
|
|
199
|
+
return { status: 'ok', code: 'probe.ok', elapsedMs: Date.now() - started, serverInfo, toolCount: tools.length, tools: tools.slice(0, 50), sessionId };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
|
|
202
|
+
return {
|
|
203
|
+
status: 'error',
|
|
204
|
+
code: aborted ? 'error.probe.timeout' : 'error.probe.network',
|
|
205
|
+
detail: aborted ? `no answer within ${timeoutMs}ms` : String(error && error.message ? error.message : error),
|
|
206
|
+
elapsedMs: Date.now() - started,
|
|
207
|
+
};
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readRpcBody(response) {
|
|
214
|
+
const type = response.headers.get('content-type') ?? '';
|
|
215
|
+
if (type.includes('text/event-stream')) {
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
for (const line of text.split('\n')) {
|
|
218
|
+
const message = parseJsonRpcLine(line);
|
|
219
|
+
if (message && (message.id !== undefined || message.result || message.error)) return message;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const text = await response.text();
|
|
225
|
+
if (!text.trim()) return null;
|
|
226
|
+
return JSON.parse(text);
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------- api --- */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Probe one canonical server. Never throws; returns a result object whose
|
|
236
|
+
* `status` is `ok` or `error` and whose `code` is a stable diagnostic id.
|
|
237
|
+
*/
|
|
238
|
+
export async function probeServer(server, { timeoutMs = 20000 } = {}) {
|
|
239
|
+
if (!server) return { status: 'error', code: 'error.probe.missing', detail: 'no server' };
|
|
240
|
+
if (server.transport === 'stdio') return probeStdio(server, timeoutMs);
|
|
241
|
+
return probeRemote(server, timeoutMs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Probe a batch with limited concurrency so we never fork the whole matrix. */
|
|
245
|
+
export async function probeMany(servers, { timeoutMs = 20000, concurrency = 4 } = {}) {
|
|
246
|
+
const results = new Map();
|
|
247
|
+
const queue = [...servers];
|
|
248
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
249
|
+
for (;;) {
|
|
250
|
+
const next = queue.shift();
|
|
251
|
+
if (!next) return;
|
|
252
|
+
results.set(next.key, await probeServer(next.server, { timeoutMs }));
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
await Promise.all(workers);
|
|
256
|
+
return results;
|
|
257
|
+
}
|