mcp-stdio-guard 0.1.0 → 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/README.md +154 -0
- package/package.json +1 -1
- package/src/index.js +1053 -22
package/src/index.js
CHANGED
|
@@ -1,10 +1,128 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function loadVersion() {
|
|
7
|
+
try {
|
|
8
|
+
const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.3.0';
|
|
10
|
+
} catch {
|
|
11
|
+
return '0.3.0';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
const DEFAULT_PROTOCOL = '2025-11-25';
|
|
6
16
|
const DEFAULT_TIMEOUT = 5000;
|
|
7
|
-
const VERSION =
|
|
17
|
+
const VERSION = loadVersion();
|
|
18
|
+
const JSON_SCHEMA_VERSION = 1;
|
|
19
|
+
const REDACTED = '<redacted>';
|
|
20
|
+
const VERSION_PROBE_CACHE = new Map();
|
|
21
|
+
const NODE_OPTIONS_WITH_VALUES = new Set([
|
|
22
|
+
'--conditions',
|
|
23
|
+
'--cpu-prof-dir',
|
|
24
|
+
'--diagnostic-dir',
|
|
25
|
+
'--experimental-loader',
|
|
26
|
+
'--heapsnapshot-near-heap-limit',
|
|
27
|
+
'--import',
|
|
28
|
+
'--inspect-port',
|
|
29
|
+
'--loader',
|
|
30
|
+
'--max-old-space-size',
|
|
31
|
+
'--openssl-config',
|
|
32
|
+
'--perf-basic-prof-only-functions',
|
|
33
|
+
'--prof-process',
|
|
34
|
+
'--redirect-warnings',
|
|
35
|
+
'--require',
|
|
36
|
+
'--test-reporter',
|
|
37
|
+
'--test-reporter-destination',
|
|
38
|
+
'--title',
|
|
39
|
+
'--trace-event-categories',
|
|
40
|
+
'--trace-event-file-pattern',
|
|
41
|
+
'-C',
|
|
42
|
+
'-r'
|
|
43
|
+
]);
|
|
44
|
+
const NODE_EVAL_OPTIONS = new Set(['--eval', '--print', '-e', '-p']);
|
|
45
|
+
|
|
46
|
+
export const ISSUE_CLASSES = Object.freeze({
|
|
47
|
+
INSTALL_RUNTIME: 'installRuntime',
|
|
48
|
+
STDIO_TRANSPORT: 'stdioTransport',
|
|
49
|
+
MCP_PROTOCOL: 'mcpProtocol'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const ISSUE_CLASS_NAMES = [
|
|
53
|
+
ISSUE_CLASSES.INSTALL_RUNTIME,
|
|
54
|
+
ISSUE_CLASSES.STDIO_TRANSPORT,
|
|
55
|
+
ISSUE_CLASSES.MCP_PROTOCOL
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const STDOUT_ISSUE_CODES = new Set([
|
|
59
|
+
'stdout-empty-line',
|
|
60
|
+
'stdout-content-length-framing',
|
|
61
|
+
'stdout-invalid-json-rpc',
|
|
62
|
+
'stdout-non-json',
|
|
63
|
+
'stdout-unexpected-request-id',
|
|
64
|
+
'stdout-without-newline'
|
|
65
|
+
]);
|
|
66
|
+
const JSON_RPC_ISSUE_CODES = new Set([
|
|
67
|
+
'notification-response',
|
|
68
|
+
'response-id-mismatch',
|
|
69
|
+
'response-id-type-mismatch',
|
|
70
|
+
'stdout-invalid-json-rpc',
|
|
71
|
+
'stdout-unexpected-request-id'
|
|
72
|
+
]);
|
|
73
|
+
const INITIALIZE_ISSUE_CODES = new Set([
|
|
74
|
+
'initialize-error',
|
|
75
|
+
'initialize-invalid-capabilities',
|
|
76
|
+
'initialize-invalid-protocol-version',
|
|
77
|
+
'initialize-invalid-result',
|
|
78
|
+
'initialize-invalid-server-info',
|
|
79
|
+
'initialize-missing-capabilities',
|
|
80
|
+
'initialize-missing-protocol-version',
|
|
81
|
+
'initialize-missing-server-info',
|
|
82
|
+
'initialize-timeout',
|
|
83
|
+
'server-exited',
|
|
84
|
+
'spawn-failed'
|
|
85
|
+
]);
|
|
86
|
+
const OPERATION_ISSUE_CODES = new Set([
|
|
87
|
+
'operation-error',
|
|
88
|
+
'operation-missing-response',
|
|
89
|
+
'operation-timeout'
|
|
90
|
+
]);
|
|
91
|
+
const PROCESS_ISSUE_CODES = new Set([
|
|
92
|
+
'server-crashed',
|
|
93
|
+
'server-exited',
|
|
94
|
+
'spawn-failed'
|
|
95
|
+
]);
|
|
96
|
+
const ISSUE_CLASS_BY_CODE = new Map([
|
|
97
|
+
['initialize-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
98
|
+
['operation-missing-response', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
99
|
+
['operation-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
100
|
+
['python-buffered-stdio', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
101
|
+
['server-crashed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
102
|
+
['server-exited', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
103
|
+
['spawn-failed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
104
|
+
|
|
105
|
+
['static-stdout-write', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
106
|
+
['stdout-content-length-framing', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
107
|
+
['stdout-empty-line', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
108
|
+
['stdout-non-json', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
109
|
+
['stdout-without-newline', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
110
|
+
|
|
111
|
+
['initialize-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
112
|
+
['initialize-invalid-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
113
|
+
['initialize-invalid-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
114
|
+
['initialize-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
115
|
+
['initialize-invalid-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
116
|
+
['initialize-missing-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
117
|
+
['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
118
|
+
['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
119
|
+
['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
120
|
+
['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
121
|
+
['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
122
|
+
['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
123
|
+
['stdout-invalid-json-rpc', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
124
|
+
['stdout-unexpected-request-id', ISSUE_CLASSES.MCP_PROTOCOL]
|
|
125
|
+
]);
|
|
8
126
|
|
|
9
127
|
export async function runCli(argv) {
|
|
10
128
|
const options = parseArgs(argv);
|
|
@@ -23,7 +141,7 @@ export async function runCli(argv) {
|
|
|
23
141
|
throw new Error('Missing command. Use: mcp-stdio-guard -- <command> [args...]');
|
|
24
142
|
}
|
|
25
143
|
|
|
26
|
-
const
|
|
144
|
+
const guardOptions = {
|
|
27
145
|
protocol: options.protocol,
|
|
28
146
|
timeoutMs: options.timeoutMs,
|
|
29
147
|
cwd: options.cwd,
|
|
@@ -33,9 +151,21 @@ export async function runCli(argv) {
|
|
|
33
151
|
params: options.requestParams
|
|
34
152
|
}
|
|
35
153
|
: null
|
|
36
|
-
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const result = options.repeat > 1
|
|
157
|
+
? await guardRepeatedStdioServer(options.command, { ...guardOptions, repeat: options.repeat })
|
|
158
|
+
: await guardStdioServer(options.command, guardOptions);
|
|
37
159
|
|
|
38
160
|
if (options.scanPath) {
|
|
161
|
+
result.staticScan = {
|
|
162
|
+
enabled: true,
|
|
163
|
+
path: options.scanPath,
|
|
164
|
+
failOnFindings: options.failOnStatic
|
|
165
|
+
};
|
|
166
|
+
if (result.fingerprint) {
|
|
167
|
+
result.fingerprint.staticScan = result.staticScan;
|
|
168
|
+
}
|
|
39
169
|
result.staticFindings = scanSource(options.scanPath);
|
|
40
170
|
if (options.failOnStatic) {
|
|
41
171
|
for (const finding of result.staticFindings) {
|
|
@@ -48,7 +178,7 @@ export async function runCli(argv) {
|
|
|
48
178
|
}
|
|
49
179
|
}
|
|
50
180
|
|
|
51
|
-
result
|
|
181
|
+
finalizeResult(result);
|
|
52
182
|
|
|
53
183
|
if (options.json) {
|
|
54
184
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -70,6 +200,7 @@ export function parseArgs(argv) {
|
|
|
70
200
|
failOnStatic: false,
|
|
71
201
|
requestMethod: '',
|
|
72
202
|
requestParams: undefined,
|
|
203
|
+
repeat: 1,
|
|
73
204
|
json: false,
|
|
74
205
|
help: false,
|
|
75
206
|
version: false,
|
|
@@ -104,6 +235,9 @@ export function parseArgs(argv) {
|
|
|
104
235
|
} else if (arg === '--timeout') {
|
|
105
236
|
options.timeoutMs = Number(readOptionValue(argv, index, arg));
|
|
106
237
|
index += 1;
|
|
238
|
+
} else if (arg === '--repeat') {
|
|
239
|
+
options.repeat = Number(readOptionValue(argv, index, arg));
|
|
240
|
+
index += 1;
|
|
107
241
|
} else if (arg === '--scan') {
|
|
108
242
|
options.scanPath = path.resolve(readOptionValue(argv, index, arg));
|
|
109
243
|
index += 1;
|
|
@@ -119,6 +253,10 @@ export function parseArgs(argv) {
|
|
|
119
253
|
throw new Error('--timeout must be an integer >= 100');
|
|
120
254
|
}
|
|
121
255
|
|
|
256
|
+
if (!Number.isInteger(options.repeat) || options.repeat < 1) {
|
|
257
|
+
throw new Error('--repeat must be an integer >= 1');
|
|
258
|
+
}
|
|
259
|
+
|
|
122
260
|
if (options.requestParams !== undefined && !options.requestMethod) {
|
|
123
261
|
throw new Error('--params can only be used with --request');
|
|
124
262
|
}
|
|
@@ -126,6 +264,46 @@ export function parseArgs(argv) {
|
|
|
126
264
|
return options;
|
|
127
265
|
}
|
|
128
266
|
|
|
267
|
+
export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
268
|
+
const startedAt = Date.now();
|
|
269
|
+
const repeat = options.repeat ?? 1;
|
|
270
|
+
const runs = [];
|
|
271
|
+
const issues = [];
|
|
272
|
+
|
|
273
|
+
if (!Number.isInteger(repeat) || repeat < 1) {
|
|
274
|
+
throw new Error('repeat must be an integer >= 1');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const singleRunOptions = { ...options, repeat: 1 };
|
|
278
|
+
|
|
279
|
+
for (let index = 1; index <= repeat; index += 1) {
|
|
280
|
+
const run = await guardStdioServer(commandWithArgs, singleRunOptions);
|
|
281
|
+
run.run = index;
|
|
282
|
+
runs.push(run);
|
|
283
|
+
for (const issue of run.issues) {
|
|
284
|
+
issues.push({ run: index, ...issue });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const durationMs = Date.now() - startedAt;
|
|
289
|
+
const result = {
|
|
290
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
291
|
+
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
292
|
+
command: commandWithArgs,
|
|
293
|
+
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
294
|
+
repeat,
|
|
295
|
+
runs,
|
|
296
|
+
issues,
|
|
297
|
+
checks: {},
|
|
298
|
+
staticScan: defaultStaticScan(),
|
|
299
|
+
staticFindings: [],
|
|
300
|
+
durationMs,
|
|
301
|
+
fingerprint: createFingerprint(commandWithArgs, options)
|
|
302
|
+
};
|
|
303
|
+
result.fingerprint.timings.totalMs = durationMs;
|
|
304
|
+
return finalizeResult(result);
|
|
305
|
+
}
|
|
306
|
+
|
|
129
307
|
export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
130
308
|
const startedAt = Date.now();
|
|
131
309
|
const command = commandWithArgs[0];
|
|
@@ -133,16 +311,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
133
311
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
134
312
|
const protocol = options.protocol ?? DEFAULT_PROTOCOL;
|
|
135
313
|
const operation = options.operation || null;
|
|
314
|
+
const env = { ...process.env, ...(options.env ?? {}) };
|
|
136
315
|
const issues = [];
|
|
137
316
|
const frames = [];
|
|
138
317
|
const stderrChunks = [];
|
|
139
318
|
let stdoutBuffer = '';
|
|
140
319
|
let initialized = false;
|
|
141
320
|
let endedByGuard = false;
|
|
321
|
+
let initializeResponseAt = 0;
|
|
142
322
|
let timer;
|
|
143
323
|
let child;
|
|
144
324
|
|
|
145
325
|
const result = {
|
|
326
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
146
327
|
ok: false,
|
|
147
328
|
command: commandWithArgs,
|
|
148
329
|
protocol,
|
|
@@ -157,20 +338,35 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
157
338
|
: null,
|
|
158
339
|
frames,
|
|
159
340
|
issues,
|
|
341
|
+
checks: {},
|
|
160
342
|
stderr: '',
|
|
343
|
+
process: defaultProcessInfo(timeoutMs),
|
|
344
|
+
staticScan: defaultStaticScan(),
|
|
161
345
|
staticFindings: [],
|
|
162
|
-
durationMs: 0
|
|
346
|
+
durationMs: 0,
|
|
347
|
+
fingerprint: createFingerprint(commandWithArgs, {
|
|
348
|
+
protocol,
|
|
349
|
+
timeoutMs,
|
|
350
|
+
cwd: options.cwd,
|
|
351
|
+
operation,
|
|
352
|
+
env: options.env
|
|
353
|
+
})
|
|
163
354
|
};
|
|
164
355
|
|
|
165
356
|
return new Promise((resolve) => {
|
|
166
|
-
function addIssue(severity, code, message) {
|
|
167
|
-
issues.push({ severity, code, message });
|
|
357
|
+
function addIssue(severity, code, message, details = {}) {
|
|
358
|
+
issues.push({ ...details, severity, code, message });
|
|
168
359
|
}
|
|
169
360
|
|
|
170
|
-
function armTimeout(code, message) {
|
|
361
|
+
function armTimeout(code, message, timeoutPhase) {
|
|
171
362
|
clearTimeout(timer);
|
|
363
|
+
result.process.phase = timeoutPhase;
|
|
172
364
|
timer = setTimeout(() => {
|
|
173
|
-
|
|
365
|
+
result.process.timedOut = true;
|
|
366
|
+
result.process.timeoutCode = code;
|
|
367
|
+
result.process.timeoutMs = timeoutMs;
|
|
368
|
+
result.process.outcome = 'timeout';
|
|
369
|
+
addIssue('error', code, message, timeoutIssueDetails(code, timeoutMs, timeoutPhase));
|
|
174
370
|
finish();
|
|
175
371
|
}, timeoutMs);
|
|
176
372
|
}
|
|
@@ -186,8 +382,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
186
382
|
result.durationMs = Date.now() - startedAt;
|
|
187
383
|
result.stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
188
384
|
result.initialized = initialized;
|
|
189
|
-
result.
|
|
190
|
-
|
|
385
|
+
result.fingerprint.timings.startupMs = initializeResponseAt ? initializeResponseAt - startedAt : null;
|
|
386
|
+
result.fingerprint.timings.totalMs = result.durationMs;
|
|
387
|
+
const willTerminate = child && result.process.started && !child.killed && child.exitCode === null;
|
|
388
|
+
if (willTerminate) {
|
|
389
|
+
result.process.killedByGuard = true;
|
|
390
|
+
result.process.killSignal = 'SIGTERM';
|
|
391
|
+
result.process.killReason = result.process.timedOut ? 'timeout' : 'guard-finished';
|
|
392
|
+
if (!result.process.outcome || result.process.outcome === 'running') {
|
|
393
|
+
result.process.outcome = 'guard-terminated';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
finalizeResult(result);
|
|
397
|
+
if (willTerminate) {
|
|
191
398
|
endedByGuard = true;
|
|
192
399
|
child.kill('SIGTERM');
|
|
193
400
|
}
|
|
@@ -195,28 +402,54 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
195
402
|
}
|
|
196
403
|
|
|
197
404
|
function send(message) {
|
|
405
|
+
if (!child?.stdin?.writable) return;
|
|
198
406
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
199
407
|
}
|
|
200
408
|
|
|
409
|
+
const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
|
|
410
|
+
if (pythonBufferingIssue) {
|
|
411
|
+
addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
|
|
412
|
+
}
|
|
413
|
+
|
|
201
414
|
child = spawn(command, args, {
|
|
202
415
|
cwd: options.cwd ?? process.cwd(),
|
|
203
|
-
env
|
|
416
|
+
env,
|
|
204
417
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
205
418
|
});
|
|
419
|
+
result.process.pid = child.pid ?? null;
|
|
206
420
|
|
|
207
|
-
armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms
|
|
421
|
+
armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`, 'initialize');
|
|
422
|
+
|
|
423
|
+
child.on('spawn', () => {
|
|
424
|
+
result.process.started = true;
|
|
425
|
+
result.process.pid = child.pid ?? null;
|
|
426
|
+
result.process.outcome = 'running';
|
|
427
|
+
});
|
|
208
428
|
|
|
209
429
|
child.on('error', (error) => {
|
|
210
430
|
clearTimeout(timer);
|
|
211
|
-
|
|
431
|
+
result.process.phase = 'startup';
|
|
432
|
+
result.process.outcome = 'spawn-failed';
|
|
433
|
+
result.process.spawnError = {
|
|
434
|
+
code: error.code || '',
|
|
435
|
+
message: error.message
|
|
436
|
+
};
|
|
437
|
+
addIssue('error', 'spawn-failed', error.message, {
|
|
438
|
+
detailCode: 'spawn-failed-before-startup',
|
|
439
|
+
phase: 'startup',
|
|
440
|
+
spawnErrorCode: error.code || ''
|
|
441
|
+
});
|
|
212
442
|
finish();
|
|
213
443
|
});
|
|
214
444
|
|
|
445
|
+
child.stdin.on('error', () => {});
|
|
446
|
+
|
|
215
447
|
child.stdout.on('data', (chunk) => {
|
|
216
448
|
stdoutBuffer += chunk.toString('utf8');
|
|
217
449
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
218
450
|
stdoutBuffer = lines.pop() ?? '';
|
|
219
451
|
for (const line of lines) {
|
|
452
|
+
if (result.durationMs) break;
|
|
220
453
|
handleStdoutLine(line);
|
|
221
454
|
}
|
|
222
455
|
});
|
|
@@ -226,18 +459,28 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
226
459
|
});
|
|
227
460
|
|
|
228
461
|
child.on('exit', (code, signal) => {
|
|
462
|
+
if (result.durationMs) return;
|
|
229
463
|
clearTimeout(timer);
|
|
464
|
+
const exitPhase = initialized
|
|
465
|
+
? result.operation && !result.operation.responded
|
|
466
|
+
? 'operation'
|
|
467
|
+
: 'post-initialize'
|
|
468
|
+
: 'initialize';
|
|
469
|
+
result.process.phase = exitPhase;
|
|
470
|
+
result.process.outcome = 'exited';
|
|
471
|
+
result.process.exitCode = code;
|
|
472
|
+
result.process.signal = signal;
|
|
230
473
|
if (stdoutBuffer.trim()) {
|
|
231
474
|
addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
|
|
232
475
|
}
|
|
233
476
|
if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
|
|
234
|
-
addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit
|
|
477
|
+
addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`, exitIssueDetails('during-operation', code, signal));
|
|
235
478
|
}
|
|
236
|
-
if (!endedByGuard && initialized && code
|
|
237
|
-
addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})
|
|
479
|
+
if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
|
|
480
|
+
addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
|
|
238
481
|
}
|
|
239
482
|
if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
|
|
240
|
-
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})
|
|
483
|
+
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('before-initialize', code, signal));
|
|
241
484
|
}
|
|
242
485
|
finish();
|
|
243
486
|
});
|
|
@@ -262,6 +505,12 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
262
505
|
return;
|
|
263
506
|
}
|
|
264
507
|
|
|
508
|
+
if (/^Content-Length\s*:/i.test(line)) {
|
|
509
|
+
addIssue('error', 'stdout-content-length-framing', 'stdout looks like LSP-style Content-Length framing; MCP stdio expects newline-delimited JSON-RPC frames');
|
|
510
|
+
finish();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
265
514
|
let message;
|
|
266
515
|
try {
|
|
267
516
|
message = JSON.parse(line);
|
|
@@ -278,15 +527,46 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
278
527
|
|
|
279
528
|
frames.push(message);
|
|
280
529
|
|
|
281
|
-
if (message
|
|
530
|
+
if (!initialized && isResponseIdTypeMismatch(message, 1)) {
|
|
531
|
+
clearTimeout(timer);
|
|
532
|
+
addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
|
|
533
|
+
finish();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!initialized && isResponseIdMismatch(message, 1)) {
|
|
282
538
|
clearTimeout(timer);
|
|
539
|
+
addIssue('error', 'response-id-mismatch', `initialize response id ${JSON.stringify(message.id)} does not match request id 1`);
|
|
540
|
+
finish();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!initialized && message.id === 1) {
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
initializeResponseAt = Date.now();
|
|
547
|
+
if (!isJsonRpcResponse(message)) {
|
|
548
|
+
addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
|
|
549
|
+
finish();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
283
553
|
if (message.error) {
|
|
284
554
|
addIssue('error', 'initialize-error', `initialize returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
285
555
|
finish();
|
|
286
556
|
return;
|
|
287
557
|
}
|
|
288
558
|
|
|
559
|
+
const initializeIssues = validateInitializeResult(message.result);
|
|
560
|
+
for (const issue of initializeIssues) {
|
|
561
|
+
addIssue(issue.severity, issue.code, issue.message);
|
|
562
|
+
}
|
|
563
|
+
if (initializeIssues.some((issue) => issue.severity === 'error')) {
|
|
564
|
+
finish();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
289
568
|
initialized = true;
|
|
569
|
+
result.process.phase = operation ? 'operation' : 'post-initialize';
|
|
290
570
|
result.negotiatedProtocol = message.result?.protocolVersion || '';
|
|
291
571
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
292
572
|
if (operation) {
|
|
@@ -299,13 +579,32 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
299
579
|
request.params = operation.params;
|
|
300
580
|
}
|
|
301
581
|
send(request);
|
|
302
|
-
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms
|
|
582
|
+
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`, 'operation');
|
|
303
583
|
} else {
|
|
304
584
|
finishSoon();
|
|
305
585
|
}
|
|
306
|
-
} else if (operation && message
|
|
586
|
+
} else if (initialized && !operation && isJsonRpcResponse(message)) {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
|
|
589
|
+
finish();
|
|
590
|
+
} else if (initialized && operation && isResponseIdTypeMismatch(message, 2)) {
|
|
591
|
+
clearTimeout(timer);
|
|
592
|
+
addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
|
|
593
|
+
finish();
|
|
594
|
+
} else if (initialized && operation && isResponseIdMismatch(message, 2)) {
|
|
307
595
|
clearTimeout(timer);
|
|
596
|
+
addIssue('error', 'response-id-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not match request id 2`);
|
|
597
|
+
finish();
|
|
598
|
+
} else if (initialized && operation && message.id === 2) {
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
if (!isJsonRpcResponse(message)) {
|
|
601
|
+
addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
|
|
602
|
+
finish();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
308
606
|
result.operation.responded = true;
|
|
607
|
+
result.process.phase = 'post-initialize';
|
|
309
608
|
if (message.error) {
|
|
310
609
|
result.operation.error = message.error;
|
|
311
610
|
addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
@@ -316,6 +615,91 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
316
615
|
});
|
|
317
616
|
}
|
|
318
617
|
|
|
618
|
+
export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
|
|
619
|
+
const command = commandWithArgs[0] || '';
|
|
620
|
+
const args = commandWithArgs.slice(1);
|
|
621
|
+
const basename = path.basename(command).toLowerCase();
|
|
622
|
+
|
|
623
|
+
if (!/^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(basename)) {
|
|
624
|
+
return '';
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (Object.hasOwn(env, 'PYTHONUNBUFFERED') && String(env.PYTHONUNBUFFERED) !== '') {
|
|
628
|
+
return '';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (args.some((arg) => arg === '-u' || /^-[^-]*u/.test(arg))) {
|
|
632
|
+
return '';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function defaultProcessInfo(timeoutMs) {
|
|
639
|
+
return {
|
|
640
|
+
started: false,
|
|
641
|
+
pid: null,
|
|
642
|
+
outcome: 'starting',
|
|
643
|
+
phase: 'initialize',
|
|
644
|
+
exitCode: null,
|
|
645
|
+
signal: null,
|
|
646
|
+
timedOut: false,
|
|
647
|
+
timeoutCode: '',
|
|
648
|
+
timeoutMs,
|
|
649
|
+
killedByGuard: false,
|
|
650
|
+
killSignal: '',
|
|
651
|
+
killReason: '',
|
|
652
|
+
spawnError: null
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function timeoutIssueDetails(code, timeoutMs, phase) {
|
|
657
|
+
return {
|
|
658
|
+
detailCode: code === 'operation-timeout' ? 'request-timeout' : 'startup-timeout',
|
|
659
|
+
phase,
|
|
660
|
+
timeoutMs
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function exitIssueDetails(position, code, signal) {
|
|
665
|
+
return {
|
|
666
|
+
detailCode: exitDetailCode(position, code, signal),
|
|
667
|
+
phase: position === 'during-operation'
|
|
668
|
+
? 'operation'
|
|
669
|
+
: position === 'before-initialize'
|
|
670
|
+
? 'initialize'
|
|
671
|
+
: 'post-initialize',
|
|
672
|
+
exitCode: code,
|
|
673
|
+
signal
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function exitDetailCode(position, code, signal) {
|
|
678
|
+
if (signal) return `signal-exit-${position}`;
|
|
679
|
+
if (code === 0) return `clean-exit-${position}`;
|
|
680
|
+
return `nonzero-exit-${position}`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function isAbnormalExit(code, signal) {
|
|
684
|
+
return signal !== null || (code !== null && code !== 0);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function isResponseIdTypeMismatch(message, expectedId) {
|
|
688
|
+
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isResponseIdMismatch(message, expectedId) {
|
|
692
|
+
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) !== String(expectedId);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isJsonRpcResponse(message) {
|
|
696
|
+
return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function hasResponsePayload(message) {
|
|
700
|
+
return Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error');
|
|
701
|
+
}
|
|
702
|
+
|
|
319
703
|
export function validateJsonRpc(message) {
|
|
320
704
|
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
321
705
|
return 'JSON-RPC frame must be an object';
|
|
@@ -330,6 +714,38 @@ export function validateJsonRpc(message) {
|
|
|
330
714
|
const hasResult = Object.hasOwn(message, 'result');
|
|
331
715
|
const hasError = Object.hasOwn(message, 'error');
|
|
332
716
|
|
|
717
|
+
if (hasId && !isJsonRpcId(message.id)) {
|
|
718
|
+
return 'JSON-RPC id must be a string, integer number, or null';
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (hasMethod && hasId && message.id === null) {
|
|
722
|
+
return 'JSON-RPC request id must not be null';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (hasMethod && (hasResult || hasError)) {
|
|
726
|
+
return 'request/notification frame must not include result or error';
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!hasMethod && hasResult && hasError) {
|
|
730
|
+
return 'response frame must not include both result and error';
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!hasMethod && !hasId && (hasResult || hasError)) {
|
|
734
|
+
return 'response frame must include id';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!hasMethod && hasError) {
|
|
738
|
+
if (!message.error || typeof message.error !== 'object' || Array.isArray(message.error)) {
|
|
739
|
+
return 'JSON-RPC error must be an object';
|
|
740
|
+
}
|
|
741
|
+
if (!Number.isInteger(message.error.code)) {
|
|
742
|
+
return 'JSON-RPC error code must be an integer';
|
|
743
|
+
}
|
|
744
|
+
if (typeof message.error.message !== 'string') {
|
|
745
|
+
return 'JSON-RPC error message must be a string';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
333
749
|
if (hasId && !hasMethod && !hasResult && !hasError) {
|
|
334
750
|
return 'response frame must include result or error';
|
|
335
751
|
}
|
|
@@ -341,6 +757,584 @@ export function validateJsonRpc(message) {
|
|
|
341
757
|
return '';
|
|
342
758
|
}
|
|
343
759
|
|
|
760
|
+
function isJsonRpcId(id) {
|
|
761
|
+
return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isInteger(id));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function validateInitializeResult(result) {
|
|
765
|
+
const issues = [];
|
|
766
|
+
|
|
767
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
768
|
+
return [{
|
|
769
|
+
severity: 'error',
|
|
770
|
+
code: 'initialize-invalid-result',
|
|
771
|
+
message: 'initialize result must be an object with protocolVersion and capabilities'
|
|
772
|
+
}];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (!Object.hasOwn(result, 'protocolVersion')) {
|
|
776
|
+
issues.push({
|
|
777
|
+
severity: 'error',
|
|
778
|
+
code: 'initialize-missing-protocol-version',
|
|
779
|
+
message: 'initialize result is missing protocolVersion'
|
|
780
|
+
});
|
|
781
|
+
} else if (typeof result.protocolVersion !== 'string' || !result.protocolVersion) {
|
|
782
|
+
issues.push({
|
|
783
|
+
severity: 'error',
|
|
784
|
+
code: 'initialize-invalid-protocol-version',
|
|
785
|
+
message: 'initialize result protocolVersion must be a non-empty string'
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (!Object.hasOwn(result, 'capabilities')) {
|
|
790
|
+
issues.push({
|
|
791
|
+
severity: 'error',
|
|
792
|
+
code: 'initialize-missing-capabilities',
|
|
793
|
+
message: 'initialize result is missing capabilities'
|
|
794
|
+
});
|
|
795
|
+
} else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
|
|
796
|
+
issues.push({
|
|
797
|
+
severity: 'error',
|
|
798
|
+
code: 'initialize-invalid-capabilities',
|
|
799
|
+
message: 'initialize result capabilities must be an object'
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!Object.hasOwn(result, 'serverInfo')) {
|
|
804
|
+
issues.push({
|
|
805
|
+
severity: 'warning',
|
|
806
|
+
code: 'initialize-missing-server-info',
|
|
807
|
+
message: 'initialize result is missing serverInfo'
|
|
808
|
+
});
|
|
809
|
+
} else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
|
|
810
|
+
issues.push({
|
|
811
|
+
severity: 'warning',
|
|
812
|
+
code: 'initialize-invalid-server-info',
|
|
813
|
+
message: 'initialize result serverInfo should be an object'
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return issues;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function classifyIssueCode(code) {
|
|
821
|
+
return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export function createFingerprint(commandWithArgs, options = {}) {
|
|
825
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
826
|
+
const operation = options.operation || null;
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
guard: {
|
|
830
|
+
name: 'mcp-stdio-guard',
|
|
831
|
+
version: VERSION
|
|
832
|
+
},
|
|
833
|
+
command: {
|
|
834
|
+
executable: commandWithArgs[0] || '',
|
|
835
|
+
args: redactArgv(commandWithArgs.slice(1)),
|
|
836
|
+
argv: redactArgv(commandWithArgs)
|
|
837
|
+
},
|
|
838
|
+
cwd: {
|
|
839
|
+
requested: String(options.cwd ?? process.cwd()),
|
|
840
|
+
resolved: cwd,
|
|
841
|
+
exists: fs.existsSync(cwd)
|
|
842
|
+
},
|
|
843
|
+
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
844
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
|
|
845
|
+
repeat: options.repeat ?? 1,
|
|
846
|
+
operation: operation
|
|
847
|
+
? {
|
|
848
|
+
method: operation.method,
|
|
849
|
+
hasParams: operation.params !== undefined
|
|
850
|
+
}
|
|
851
|
+
: null,
|
|
852
|
+
system: {
|
|
853
|
+
platform: process.platform,
|
|
854
|
+
arch: process.arch,
|
|
855
|
+
osRelease: os.release()
|
|
856
|
+
},
|
|
857
|
+
runtimes: detectRuntimeVersions(commandWithArgs),
|
|
858
|
+
package: detectPackageMetadata(commandWithArgs, cwd),
|
|
859
|
+
env: redactEnvMetadata(options.env ?? {}),
|
|
860
|
+
staticScan: defaultStaticScan(),
|
|
861
|
+
timings: {
|
|
862
|
+
startupMs: null,
|
|
863
|
+
totalMs: null
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function redactEnvMetadata(env) {
|
|
869
|
+
const names = Object.keys(env).sort();
|
|
870
|
+
return {
|
|
871
|
+
inherited: true,
|
|
872
|
+
names,
|
|
873
|
+
values: Object.fromEntries(names.map((name) => [name, REDACTED]))
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function redactArgv(argv) {
|
|
878
|
+
const redacted = [];
|
|
879
|
+
let redactNext = false;
|
|
880
|
+
|
|
881
|
+
for (const arg of argv) {
|
|
882
|
+
if (redactNext) {
|
|
883
|
+
redacted.push(REDACTED);
|
|
884
|
+
redactNext = false;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const secretAssignment = redactSecretAssignment(arg);
|
|
889
|
+
if (secretAssignment) {
|
|
890
|
+
redacted.push(secretAssignment);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
redacted.push(arg);
|
|
895
|
+
if (isSecretFlag(arg)) {
|
|
896
|
+
redactNext = true;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return redacted;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function redactSecretAssignment(arg) {
|
|
904
|
+
const match = /^([^=\s]+)=(.*)$/.exec(arg);
|
|
905
|
+
if (!match) return '';
|
|
906
|
+
|
|
907
|
+
const [, name] = match;
|
|
908
|
+
return isSecretName(name) ? `${name}=${REDACTED}` : '';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function isSecretFlag(arg) {
|
|
912
|
+
if (!arg.startsWith('-')) return false;
|
|
913
|
+
const name = arg.replace(/^-+/, '').split(/[=\s]/)[0];
|
|
914
|
+
return isSecretName(name);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function isSecretName(name) {
|
|
918
|
+
return /(^|[-_])(api[-_]?key|auth|bearer|cookie|credential|password|passwd|private[-_]?key|pwd|secret|session|token)([-_]|$)/i.test(name);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function detectRuntimeVersions(commandWithArgs) {
|
|
922
|
+
const command = commandWithArgs[0] || '';
|
|
923
|
+
const base = path.basename(command).toLowerCase();
|
|
924
|
+
const runtimes = {
|
|
925
|
+
node: {
|
|
926
|
+
version: process.version,
|
|
927
|
+
role: isNodeCommand(command) ? 'guard-and-target' : 'guard'
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (isPythonCommand(command)) {
|
|
932
|
+
runtimes.python = executableVersion(command, ['--version']);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (isNpmCommand(base) || isNpxCommand(base)) {
|
|
936
|
+
runtimes.npm = executableVersion('npm', ['--version']);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (base === 'uv' || base === 'uvx') {
|
|
940
|
+
runtimes.uv = executableVersion(base === 'uvx' ? 'uv' : command, ['--version']);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (base === 'docker') {
|
|
944
|
+
runtimes.docker = executableVersion(command, ['--version']);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return runtimes;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function executableVersion(command, args) {
|
|
951
|
+
const cacheKey = JSON.stringify([command, args]);
|
|
952
|
+
if (VERSION_PROBE_CACHE.has(cacheKey)) {
|
|
953
|
+
return { ...VERSION_PROBE_CACHE.get(cacheKey) };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const result = spawnSync(command, args, {
|
|
957
|
+
encoding: 'utf8',
|
|
958
|
+
timeout: 1000,
|
|
959
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
960
|
+
});
|
|
961
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
962
|
+
const version = {
|
|
963
|
+
command,
|
|
964
|
+
version: output.split(/\r?\n/)[0] || '',
|
|
965
|
+
available: !result.error && result.status === 0 && result.signal === null,
|
|
966
|
+
status: result.status,
|
|
967
|
+
signal: result.signal
|
|
968
|
+
};
|
|
969
|
+
VERSION_PROBE_CACHE.set(cacheKey, version);
|
|
970
|
+
return { ...version };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function detectPackageMetadata(commandWithArgs, cwd) {
|
|
974
|
+
const command = commandWithArgs[0] || '';
|
|
975
|
+
const args = commandWithArgs.slice(1);
|
|
976
|
+
const base = path.basename(command).toLowerCase();
|
|
977
|
+
|
|
978
|
+
if (isNpxCommand(base)) {
|
|
979
|
+
return packageFromSpec('npm', firstPackageSpec(args));
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (isNpmCommand(base)) {
|
|
983
|
+
const subcommand = args[0] || '';
|
|
984
|
+
if (subcommand === 'exec' || subcommand === 'x') {
|
|
985
|
+
return packageFromSpec('npm', firstPackageSpec(args.slice(1)));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (base === 'uvx') {
|
|
990
|
+
return packageFromSpec('uv', firstPackageSpec(args));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (base === 'docker') {
|
|
994
|
+
const image = dockerImageSpec(args);
|
|
995
|
+
return image ? { manager: 'docker', name: image, versionSpec: '' } : null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (isNodeCommand(command)) {
|
|
999
|
+
const entrypoint = nodeEntrypointArg(args);
|
|
1000
|
+
return entrypoint ? localPackageMetadata(path.resolve(cwd, entrypoint)) : null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function firstPackageSpec(args) {
|
|
1007
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1008
|
+
const arg = args[index];
|
|
1009
|
+
if (arg === '--') continue;
|
|
1010
|
+
|
|
1011
|
+
if (arg.startsWith('--package=')) {
|
|
1012
|
+
return arg.slice('--package='.length);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (arg === '--package' || arg === '-p') {
|
|
1016
|
+
return args[index + 1] || '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (arg.startsWith('-')) {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return arg;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return '';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function nodeEntrypointArg(args) {
|
|
1030
|
+
let afterSeparator = false;
|
|
1031
|
+
|
|
1032
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1033
|
+
const arg = args[index];
|
|
1034
|
+
|
|
1035
|
+
if (!afterSeparator && arg === '--') {
|
|
1036
|
+
afterSeparator = true;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (!afterSeparator && arg.startsWith('-')) {
|
|
1041
|
+
const optionName = arg.split('=')[0];
|
|
1042
|
+
if (NODE_EVAL_OPTIONS.has(optionName)) {
|
|
1043
|
+
return '';
|
|
1044
|
+
}
|
|
1045
|
+
if (NODE_OPTIONS_WITH_VALUES.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1046
|
+
index += 1;
|
|
1047
|
+
}
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return arg;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return '';
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function packageFromSpec(manager, spec) {
|
|
1058
|
+
if (!spec) return null;
|
|
1059
|
+
const parsed = parsePackageSpec(spec);
|
|
1060
|
+
return {
|
|
1061
|
+
manager,
|
|
1062
|
+
name: parsed.name,
|
|
1063
|
+
versionSpec: parsed.versionSpec
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function parsePackageSpec(spec) {
|
|
1068
|
+
if (spec.startsWith('@')) {
|
|
1069
|
+
const versionAt = spec.indexOf('@', 1);
|
|
1070
|
+
if (versionAt > -1) {
|
|
1071
|
+
return {
|
|
1072
|
+
name: spec.slice(0, versionAt),
|
|
1073
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
return { name: spec, versionSpec: '' };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const versionAt = spec.lastIndexOf('@');
|
|
1080
|
+
if (versionAt > 0) {
|
|
1081
|
+
return {
|
|
1082
|
+
name: spec.slice(0, versionAt),
|
|
1083
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return { name: spec, versionSpec: '' };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function dockerImageSpec(args) {
|
|
1091
|
+
const runIndex = args.indexOf('run');
|
|
1092
|
+
if (runIndex === -1) return '';
|
|
1093
|
+
const optionsWithValues = new Set([
|
|
1094
|
+
'-e',
|
|
1095
|
+
'--env',
|
|
1096
|
+
'-h',
|
|
1097
|
+
'--hostname',
|
|
1098
|
+
'-p',
|
|
1099
|
+
'--publish',
|
|
1100
|
+
'-u',
|
|
1101
|
+
'--user',
|
|
1102
|
+
'-v',
|
|
1103
|
+
'--volume',
|
|
1104
|
+
'-w',
|
|
1105
|
+
'--workdir',
|
|
1106
|
+
'--entrypoint',
|
|
1107
|
+
'--name',
|
|
1108
|
+
'--network',
|
|
1109
|
+
'--platform'
|
|
1110
|
+
]);
|
|
1111
|
+
|
|
1112
|
+
for (let index = runIndex + 1; index < args.length; index += 1) {
|
|
1113
|
+
const arg = args[index];
|
|
1114
|
+
if (arg === '--') continue;
|
|
1115
|
+
if (arg.startsWith('-')) {
|
|
1116
|
+
const optionName = arg.split('=')[0];
|
|
1117
|
+
if (optionsWithValues.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1118
|
+
index += 1;
|
|
1119
|
+
}
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
return arg;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return '';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function localPackageMetadata(entry) {
|
|
1129
|
+
const packageJson = findNearestPackageJson(fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? entry : path.dirname(entry));
|
|
1130
|
+
if (!packageJson) return null;
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
|
|
1134
|
+
return {
|
|
1135
|
+
manager: 'local',
|
|
1136
|
+
name: typeof parsed.name === 'string' ? parsed.name : '',
|
|
1137
|
+
versionSpec: typeof parsed.version === 'string' ? parsed.version : ''
|
|
1138
|
+
};
|
|
1139
|
+
} catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function findNearestPackageJson(start) {
|
|
1145
|
+
let dir = path.resolve(start);
|
|
1146
|
+
while (dir !== path.dirname(dir)) {
|
|
1147
|
+
const candidate = path.join(dir, 'package.json');
|
|
1148
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1149
|
+
dir = path.dirname(dir);
|
|
1150
|
+
}
|
|
1151
|
+
return '';
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isNodeCommand(command) {
|
|
1155
|
+
const base = path.basename(command).toLowerCase();
|
|
1156
|
+
return base === 'node' || base === 'node.exe' || command === process.execPath;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function isPythonCommand(command) {
|
|
1160
|
+
const base = path.basename(command).toLowerCase();
|
|
1161
|
+
return /^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(base);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function isNpmCommand(base) {
|
|
1165
|
+
return base === 'npm' || base === 'npm.cmd' || base === 'npm-cli.js';
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function isNpxCommand(base) {
|
|
1169
|
+
return base === 'npx' || base === 'npx.cmd';
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function finalizeResult(result) {
|
|
1173
|
+
result.schemaVersion = JSON_SCHEMA_VERSION;
|
|
1174
|
+
result.staticScan ??= defaultStaticScan();
|
|
1175
|
+
result.staticFindings ??= [];
|
|
1176
|
+
result.issues = normalizeIssues(result.issues ?? []);
|
|
1177
|
+
result.ok = !result.issues.some((issue) => issue.severity === 'error');
|
|
1178
|
+
result.checks = buildChecks(result);
|
|
1179
|
+
result.issueClasses = buildIssueClasses(result.issues);
|
|
1180
|
+
finalizeFingerprint(result);
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function finalizeFingerprint(result) {
|
|
1185
|
+
if (!result.fingerprint) return;
|
|
1186
|
+
result.fingerprint.timings ??= {};
|
|
1187
|
+
result.fingerprint.timings.totalMs = result.durationMs ?? result.fingerprint.timings.totalMs ?? null;
|
|
1188
|
+
|
|
1189
|
+
if (Array.isArray(result.runs)) {
|
|
1190
|
+
result.fingerprint.runs = result.runs.map((run) => ({
|
|
1191
|
+
run: run.run,
|
|
1192
|
+
ok: run.ok,
|
|
1193
|
+
startupMs: run.fingerprint?.timings?.startupMs ?? null,
|
|
1194
|
+
totalMs: run.durationMs ?? run.fingerprint?.timings?.totalMs ?? null
|
|
1195
|
+
}));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function normalizeIssues(issues) {
|
|
1200
|
+
return issues.map((issue) => ({
|
|
1201
|
+
...issue,
|
|
1202
|
+
class: classifyIssueCode(issue.code)
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function buildChecks(result) {
|
|
1207
|
+
const issues = result.issues ?? [];
|
|
1208
|
+
const repeated = Array.isArray(result.runs);
|
|
1209
|
+
|
|
1210
|
+
return {
|
|
1211
|
+
initialize: repeated
|
|
1212
|
+
? aggregateRunCheck(result, 'initialize')
|
|
1213
|
+
: buildInitializeCheck(result, issues),
|
|
1214
|
+
stdout: buildIssueCheck(issues, (issue) => STDOUT_ISSUE_CODES.has(issue.code)),
|
|
1215
|
+
jsonRpc: buildIssueCheck(issues, (issue) => JSON_RPC_ISSUE_CODES.has(issue.code)),
|
|
1216
|
+
operation: repeated
|
|
1217
|
+
? aggregateRunCheck(result, 'operation')
|
|
1218
|
+
: buildOperationCheck(result, issues),
|
|
1219
|
+
process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
|
|
1220
|
+
pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
|
|
1221
|
+
staticScan: buildStaticScanCheck(result, issues),
|
|
1222
|
+
repeat: buildRepeatCheck(result)
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function buildIssueClasses(issues) {
|
|
1227
|
+
return Object.fromEntries(ISSUE_CLASS_NAMES.map((className) => [
|
|
1228
|
+
className,
|
|
1229
|
+
buildIssueCheck(issues, (issue) => issue.class === className)
|
|
1230
|
+
]));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function buildInitializeCheck(result, issues) {
|
|
1234
|
+
const matched = issues.filter((issue) => (
|
|
1235
|
+
INITIALIZE_ISSUE_CODES.has(issue.code)
|
|
1236
|
+
|| (!result.initialized && STDOUT_ISSUE_CODES.has(issue.code))
|
|
1237
|
+
|| (!result.initialized && JSON_RPC_ISSUE_CODES.has(issue.code))
|
|
1238
|
+
));
|
|
1239
|
+
if (matched.length) return makeCheck(statusFromIssues(matched), matched);
|
|
1240
|
+
return makeCheck(result.initialized ? 'pass' : 'fail', []);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function buildOperationCheck(result, issues) {
|
|
1244
|
+
if (!result.operation) {
|
|
1245
|
+
return makeCheck('skipped', []);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (!result.initialized) {
|
|
1249
|
+
return makeCheck('skipped', []);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const matched = issues.filter((issue) => (
|
|
1253
|
+
OPERATION_ISSUE_CODES.has(issue.code)
|
|
1254
|
+
|| (!result.operation.responded && STDOUT_ISSUE_CODES.has(issue.code))
|
|
1255
|
+
|| (!result.operation.responded && JSON_RPC_ISSUE_CODES.has(issue.code))
|
|
1256
|
+
));
|
|
1257
|
+
if (matched.length) {
|
|
1258
|
+
return makeCheck(statusFromIssues(matched), matched);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return makeCheck(result.operation.responded ? 'pass' : 'fail', []);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function buildStaticScanCheck(result, issues) {
|
|
1265
|
+
if (!result.staticScan?.enabled) {
|
|
1266
|
+
return makeCheck('skipped', []);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const matched = issues.filter((issue) => issue.code === 'static-stdout-write');
|
|
1270
|
+
if (matched.length) {
|
|
1271
|
+
return makeCheck(statusFromIssues(matched), matched);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (result.staticFindings?.length) {
|
|
1275
|
+
return makeCheck('warning', [{ code: 'static-stdout-write' }]);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return makeCheck('pass', []);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function buildRepeatCheck(result) {
|
|
1282
|
+
if (!Array.isArray(result.runs)) {
|
|
1283
|
+
return makeCheck('skipped', []);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
|
|
1287
|
+
return {
|
|
1288
|
+
...makeCheck(failedRuns.length ? 'fail' : 'pass', result.issues ?? []),
|
|
1289
|
+
runs: result.runs.length,
|
|
1290
|
+
passedRuns: result.runs.length - failedRuns.length,
|
|
1291
|
+
failedRuns
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function aggregateRunCheck(result, checkName) {
|
|
1296
|
+
const checks = result.runs
|
|
1297
|
+
.map((run) => run.checks?.[checkName])
|
|
1298
|
+
.filter(Boolean)
|
|
1299
|
+
.filter((check) => check.status !== 'skipped');
|
|
1300
|
+
|
|
1301
|
+
if (!checks.length) {
|
|
1302
|
+
return makeCheck('skipped', []);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const status = checks.some((check) => check.status === 'fail')
|
|
1306
|
+
? 'fail'
|
|
1307
|
+
: checks.some((check) => check.status === 'warning')
|
|
1308
|
+
? 'warning'
|
|
1309
|
+
: 'pass';
|
|
1310
|
+
const issueCodes = [...new Set(checks.flatMap((check) => check.issueCodes))].sort();
|
|
1311
|
+
return { status, issueCodes };
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function buildIssueCheck(issues, predicate) {
|
|
1315
|
+
const matched = issues.filter(predicate);
|
|
1316
|
+
return makeCheck(matched.length ? statusFromIssues(matched) : 'pass', matched);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function statusFromIssues(issues) {
|
|
1320
|
+
return issues.some((issue) => issue.severity === 'error') ? 'fail' : 'warning';
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function makeCheck(status, issues) {
|
|
1324
|
+
return {
|
|
1325
|
+
status,
|
|
1326
|
+
issueCodes: [...new Set(issues.map((issue) => issue.code))].sort()
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function defaultStaticScan() {
|
|
1331
|
+
return {
|
|
1332
|
+
enabled: false,
|
|
1333
|
+
path: '',
|
|
1334
|
+
failOnFindings: false
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
344
1338
|
export function scanSource(root) {
|
|
345
1339
|
const findings = [];
|
|
346
1340
|
const absoluteRoot = path.resolve(root);
|
|
@@ -414,6 +1408,10 @@ function listSourceFiles(root) {
|
|
|
414
1408
|
}
|
|
415
1409
|
|
|
416
1410
|
function formatTextResult(result) {
|
|
1411
|
+
if (Array.isArray(result.runs)) {
|
|
1412
|
+
return formatRepeatedTextResult(result);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
417
1415
|
const status = result.ok ? 'PASS' : 'FAIL';
|
|
418
1416
|
const invalidFrames = result.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
|
|
419
1417
|
const stderrLines = result.stderr ? result.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
|
|
@@ -447,6 +1445,36 @@ function formatTextResult(result) {
|
|
|
447
1445
|
return lines.join('\n');
|
|
448
1446
|
}
|
|
449
1447
|
|
|
1448
|
+
function formatRepeatedTextResult(result) {
|
|
1449
|
+
const status = result.ok ? 'PASS' : 'FAIL';
|
|
1450
|
+
const passedRuns = result.runs.filter((run) => run.ok).length;
|
|
1451
|
+
const lines = [
|
|
1452
|
+
`${status} MCP stdio guard`,
|
|
1453
|
+
`runs: ${passedRuns}/${result.runs.length} passed`
|
|
1454
|
+
];
|
|
1455
|
+
|
|
1456
|
+
for (const run of result.runs) {
|
|
1457
|
+
const runStatus = run.ok ? 'PASS' : 'FAIL';
|
|
1458
|
+
const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
|
|
1459
|
+
const stderrLines = run.stderr ? run.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
|
|
1460
|
+
lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines`);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (result.staticFindings.length) {
|
|
1464
|
+
lines.push(`static findings: ${result.staticFindings.length}`);
|
|
1465
|
+
for (const finding of result.staticFindings.slice(0, 10)) {
|
|
1466
|
+
lines.push(`[warning] ${finding.file}:${finding.line} ${finding.message}`);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
for (const issue of result.issues) {
|
|
1471
|
+
const prefix = issue.run ? `run ${issue.run} ` : '';
|
|
1472
|
+
lines.push(`[${issue.severity}] ${prefix}${issue.code}: ${issue.message}`);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return lines.join('\n');
|
|
1476
|
+
}
|
|
1477
|
+
|
|
450
1478
|
function readOptionValue(argv, index, option) {
|
|
451
1479
|
const value = argv[index + 1];
|
|
452
1480
|
if (!value || value.startsWith('--')) {
|
|
@@ -471,12 +1499,15 @@ function quote(value) {
|
|
|
471
1499
|
function helpText() {
|
|
472
1500
|
return `mcp-stdio-guard validates MCP stdio servers.
|
|
473
1501
|
|
|
1502
|
+
MCP stdio output is expected as newline-delimited JSON-RPC on stdout.
|
|
1503
|
+
|
|
474
1504
|
Usage:
|
|
475
1505
|
mcp-stdio-guard [options] -- <command> [args...]
|
|
476
1506
|
|
|
477
1507
|
Options:
|
|
478
1508
|
--protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
|
|
479
1509
|
--timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
|
|
1510
|
+
--repeat <count> run the guard multiple times, default 1
|
|
480
1511
|
--scan <path> scan source for risky stdout writes
|
|
481
1512
|
--fail-on-static fail when --scan finds risky stdout writes
|
|
482
1513
|
--request <method> send one MCP request after initialize, e.g. tools/list
|