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.
Files changed (3) hide show
  1. package/README.md +154 -0
  2. package/package.json +1 -1
  3. 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 = '0.1.0';
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 result = await guardStdioServer(options.command, {
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.ok = !result.issues.some((issue) => issue.severity === 'error');
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
- addIssue('error', code, message);
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.ok = !issues.some((issue) => issue.severity === 'error');
190
- if (child && !child.killed && child.exitCode === null) {
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: process.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
- addIssue('error', 'spawn-failed', error.message);
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 && code !== 0) {
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.id === 1) {
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.id === 2) {
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