mcp-stdio-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +50 -0
  2. package/package.json +1 -1
  3. package/src/index.js +344 -6
package/README.md CHANGED
@@ -77,6 +77,12 @@ JSON output for CI:
77
77
  mcp-stdio-guard --json --request tools/list -- node ./server.js
78
78
  ```
79
79
 
80
+ Repeat the same guard to catch cold/warm startup behavior:
81
+
82
+ ```bash
83
+ mcp-stdio-guard --repeat 2 --request tools/list -- node ./server.js
84
+ ```
85
+
80
86
  ## What It Catches
81
87
 
82
88
  <p align="center">
@@ -86,6 +92,7 @@ mcp-stdio-guard --json --request tools/list -- node ./server.js
86
92
  | Problem | Runtime check | Static scan |
87
93
  | --- | --- | --- |
88
94
  | `console.log("starting")` before server startup | Yes | Yes |
95
+ | Dependency/import-time stdout pollution | Yes with `--repeat` | No |
89
96
  | Python `print("debug")` in a stdio server | Yes | Yes |
90
97
  | Late stdout logs after `initialize` | Yes | Partial |
91
98
  | Invalid JSON-RPC frames | Yes | No |
@@ -116,6 +123,7 @@ mcp-stdio-guard [options] -- <command> [args...]
116
123
  | --- | --- |
117
124
  | `--protocol <version>` | MCP protocol version to send, default `2025-11-25` |
118
125
  | `--timeout <ms>` | initialize and request timeout, default `5000` |
126
+ | `--repeat <count>` | run the same guard multiple times to catch cold/warm startup behavior |
119
127
  | `--request <method>` | send one MCP request after initialization, for example `tools/list` |
120
128
  | `--params <json>` | JSON params for `--request` |
121
129
  | `--scan <path>` | scan source for risky stdout writes |
@@ -124,6 +132,48 @@ mcp-stdio-guard [options] -- <command> [args...]
124
132
  | `--cwd <path>` | run the server command from a specific directory |
125
133
  | `--help` | show help |
126
134
 
135
+ ## JSON Contract
136
+
137
+ `--json` is intended for CI, registries, and badge ingestion. The current contract is `schemaVersion: 1`; new fields may be added, but these fields are stable for consumers:
138
+
139
+ | Field | Meaning |
140
+ | --- | --- |
141
+ | `schemaVersion` | JSON contract version, currently `1` |
142
+ | `ok` | `true` when no error-severity issue was found |
143
+ | `command` | command and arguments that were validated |
144
+ | `protocol` | MCP protocol version sent by the guard |
145
+ | `negotiatedProtocol` | protocol version returned by the server, when available |
146
+ | `initialized` | whether the server completed the initialize handshake |
147
+ | `operation` | post-initialize request result, or `null` when `--request` was not used |
148
+ | `checks` | badge-friendly per-class statuses |
149
+ | `issues` | machine-readable diagnostics with `severity`, `code`, and `message`; repeat mode also adds `run` |
150
+ | `staticScan` | whether source scanning was enabled and whether findings fail the command |
151
+ | `staticFindings` | source scan findings with file, line, and message |
152
+ | `runs` | per-run results when `--repeat` is used |
153
+
154
+ Check statuses are `pass`, `fail`, `warning`, or `skipped`. The `checks` object separates the signal into `initialize`, `stdout`, `jsonRpc`, `operation`, `process`, `pythonBuffering`, `staticScan`, and `repeat`, each with stable `status` and `issueCodes` fields. When `--repeat` is used, `checks.repeat` also includes `runs`, `passedRuns`, and `failedRuns`; each entry in `runs` is a normal schema-versioned result for that individual guard run.
155
+
156
+ Example:
157
+
158
+ ```json
159
+ {
160
+ "schemaVersion": 1,
161
+ "ok": true,
162
+ "checks": {
163
+ "initialize": { "status": "pass", "issueCodes": [] },
164
+ "stdout": { "status": "pass", "issueCodes": [] },
165
+ "jsonRpc": { "status": "pass", "issueCodes": [] },
166
+ "operation": { "status": "pass", "issueCodes": [] },
167
+ "process": { "status": "pass", "issueCodes": [] },
168
+ "pythonBuffering": { "status": "pass", "issueCodes": [] },
169
+ "staticScan": { "status": "skipped", "issueCodes": [] },
170
+ "repeat": { "status": "skipped", "issueCodes": [] }
171
+ }
172
+ }
173
+ ```
174
+
175
+ The guard is registry-agnostic. It does not care whether an install command came from Smithery, Glama, GitHub, or a private catalog; it validates the command, working directory, optional source path, and observed stdio behavior.
176
+
127
177
  ## CI
128
178
 
129
179
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-stdio-guard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A runtime zero-dependency CLI that catches stdout pollution and handshake failures in MCP stdio servers.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -2,9 +2,49 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
 
5
+ function loadVersion() {
6
+ try {
7
+ const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
+ return typeof packageJson.version === 'string' ? packageJson.version : '0.2.0';
9
+ } catch {
10
+ return '0.2.0';
11
+ }
12
+ }
13
+
5
14
  const DEFAULT_PROTOCOL = '2025-11-25';
6
15
  const DEFAULT_TIMEOUT = 5000;
7
- const VERSION = '0.1.0';
16
+ const VERSION = loadVersion();
17
+ const JSON_SCHEMA_VERSION = 1;
18
+
19
+ const STDOUT_ISSUE_CODES = new Set([
20
+ 'stdout-empty-line',
21
+ 'stdout-content-length-framing',
22
+ 'stdout-invalid-json-rpc',
23
+ 'stdout-non-json',
24
+ 'stdout-unexpected-request-id',
25
+ 'stdout-without-newline'
26
+ ]);
27
+ const JSON_RPC_ISSUE_CODES = new Set([
28
+ 'response-id-type-mismatch',
29
+ 'stdout-invalid-json-rpc',
30
+ 'stdout-unexpected-request-id'
31
+ ]);
32
+ const INITIALIZE_ISSUE_CODES = new Set([
33
+ 'initialize-error',
34
+ 'initialize-timeout',
35
+ 'server-exited',
36
+ 'spawn-failed'
37
+ ]);
38
+ const OPERATION_ISSUE_CODES = new Set([
39
+ 'operation-error',
40
+ 'operation-missing-response',
41
+ 'operation-timeout'
42
+ ]);
43
+ const PROCESS_ISSUE_CODES = new Set([
44
+ 'server-crashed',
45
+ 'server-exited',
46
+ 'spawn-failed'
47
+ ]);
8
48
 
9
49
  export async function runCli(argv) {
10
50
  const options = parseArgs(argv);
@@ -23,7 +63,7 @@ export async function runCli(argv) {
23
63
  throw new Error('Missing command. Use: mcp-stdio-guard -- <command> [args...]');
24
64
  }
25
65
 
26
- const result = await guardStdioServer(options.command, {
66
+ const guardOptions = {
27
67
  protocol: options.protocol,
28
68
  timeoutMs: options.timeoutMs,
29
69
  cwd: options.cwd,
@@ -33,9 +73,18 @@ export async function runCli(argv) {
33
73
  params: options.requestParams
34
74
  }
35
75
  : null
36
- });
76
+ };
77
+
78
+ const result = options.repeat > 1
79
+ ? await guardRepeatedStdioServer(options.command, { ...guardOptions, repeat: options.repeat })
80
+ : await guardStdioServer(options.command, guardOptions);
37
81
 
38
82
  if (options.scanPath) {
83
+ result.staticScan = {
84
+ enabled: true,
85
+ path: options.scanPath,
86
+ failOnFindings: options.failOnStatic
87
+ };
39
88
  result.staticFindings = scanSource(options.scanPath);
40
89
  if (options.failOnStatic) {
41
90
  for (const finding of result.staticFindings) {
@@ -48,7 +97,7 @@ export async function runCli(argv) {
48
97
  }
49
98
  }
50
99
 
51
- result.ok = !result.issues.some((issue) => issue.severity === 'error');
100
+ finalizeResult(result);
52
101
 
53
102
  if (options.json) {
54
103
  console.log(JSON.stringify(result, null, 2));
@@ -70,6 +119,7 @@ export function parseArgs(argv) {
70
119
  failOnStatic: false,
71
120
  requestMethod: '',
72
121
  requestParams: undefined,
122
+ repeat: 1,
73
123
  json: false,
74
124
  help: false,
75
125
  version: false,
@@ -104,6 +154,9 @@ export function parseArgs(argv) {
104
154
  } else if (arg === '--timeout') {
105
155
  options.timeoutMs = Number(readOptionValue(argv, index, arg));
106
156
  index += 1;
157
+ } else if (arg === '--repeat') {
158
+ options.repeat = Number(readOptionValue(argv, index, arg));
159
+ index += 1;
107
160
  } else if (arg === '--scan') {
108
161
  options.scanPath = path.resolve(readOptionValue(argv, index, arg));
109
162
  index += 1;
@@ -119,6 +172,10 @@ export function parseArgs(argv) {
119
172
  throw new Error('--timeout must be an integer >= 100');
120
173
  }
121
174
 
175
+ if (!Number.isInteger(options.repeat) || options.repeat < 1) {
176
+ throw new Error('--repeat must be an integer >= 1');
177
+ }
178
+
122
179
  if (options.requestParams !== undefined && !options.requestMethod) {
123
180
  throw new Error('--params can only be used with --request');
124
181
  }
@@ -126,6 +183,40 @@ export function parseArgs(argv) {
126
183
  return options;
127
184
  }
128
185
 
186
+ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
187
+ const startedAt = Date.now();
188
+ const repeat = options.repeat ?? 1;
189
+ const runs = [];
190
+ const issues = [];
191
+
192
+ if (!Number.isInteger(repeat) || repeat < 1) {
193
+ throw new Error('repeat must be an integer >= 1');
194
+ }
195
+
196
+ for (let index = 1; index <= repeat; index += 1) {
197
+ const run = await guardStdioServer(commandWithArgs, options);
198
+ run.run = index;
199
+ runs.push(run);
200
+ for (const issue of run.issues) {
201
+ issues.push({ run: index, ...issue });
202
+ }
203
+ }
204
+
205
+ return finalizeResult({
206
+ schemaVersion: JSON_SCHEMA_VERSION,
207
+ ok: !issues.some((issue) => issue.severity === 'error'),
208
+ command: commandWithArgs,
209
+ protocol: options.protocol ?? DEFAULT_PROTOCOL,
210
+ repeat,
211
+ runs,
212
+ issues,
213
+ checks: {},
214
+ staticScan: defaultStaticScan(),
215
+ staticFindings: [],
216
+ durationMs: Date.now() - startedAt
217
+ });
218
+ }
219
+
129
220
  export async function guardStdioServer(commandWithArgs, options = {}) {
130
221
  const startedAt = Date.now();
131
222
  const command = commandWithArgs[0];
@@ -133,6 +224,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
133
224
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
134
225
  const protocol = options.protocol ?? DEFAULT_PROTOCOL;
135
226
  const operation = options.operation || null;
227
+ const env = { ...process.env, ...(options.env ?? {}) };
136
228
  const issues = [];
137
229
  const frames = [];
138
230
  const stderrChunks = [];
@@ -143,6 +235,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
143
235
  let child;
144
236
 
145
237
  const result = {
238
+ schemaVersion: JSON_SCHEMA_VERSION,
146
239
  ok: false,
147
240
  command: commandWithArgs,
148
241
  protocol,
@@ -157,7 +250,9 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
157
250
  : null,
158
251
  frames,
159
252
  issues,
253
+ checks: {},
160
254
  stderr: '',
255
+ staticScan: defaultStaticScan(),
161
256
  staticFindings: [],
162
257
  durationMs: 0
163
258
  };
@@ -186,7 +281,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
186
281
  result.durationMs = Date.now() - startedAt;
187
282
  result.stderr = Buffer.concat(stderrChunks).toString('utf8');
188
283
  result.initialized = initialized;
189
- result.ok = !issues.some((issue) => issue.severity === 'error');
284
+ finalizeResult(result);
190
285
  if (child && !child.killed && child.exitCode === null) {
191
286
  endedByGuard = true;
192
287
  child.kill('SIGTERM');
@@ -198,9 +293,14 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
198
293
  child.stdin.write(`${JSON.stringify(message)}\n`);
199
294
  }
200
295
 
296
+ const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
297
+ if (pythonBufferingIssue) {
298
+ addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
299
+ }
300
+
201
301
  child = spawn(command, args, {
202
302
  cwd: options.cwd ?? process.cwd(),
203
- env: process.env,
303
+ env,
204
304
  stdio: ['pipe', 'pipe', 'pipe']
205
305
  });
206
306
 
@@ -217,6 +317,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
217
317
  const lines = stdoutBuffer.split(/\r?\n/);
218
318
  stdoutBuffer = lines.pop() ?? '';
219
319
  for (const line of lines) {
320
+ if (result.durationMs) break;
220
321
  handleStdoutLine(line);
221
322
  }
222
323
  });
@@ -226,6 +327,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
226
327
  });
227
328
 
228
329
  child.on('exit', (code, signal) => {
330
+ if (result.durationMs) return;
229
331
  clearTimeout(timer);
230
332
  if (stdoutBuffer.trim()) {
231
333
  addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
@@ -262,6 +364,12 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
262
364
  return;
263
365
  }
264
366
 
367
+ if (/^Content-Length\s*:/i.test(line)) {
368
+ addIssue('error', 'stdout-content-length-framing', 'stdout looks like LSP-style Content-Length framing; MCP stdio expects newline-delimited JSON-RPC frames');
369
+ finish();
370
+ return;
371
+ }
372
+
265
373
  let message;
266
374
  try {
267
375
  message = JSON.parse(line);
@@ -278,8 +386,21 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
278
386
 
279
387
  frames.push(message);
280
388
 
389
+ if (isResponseIdTypeMismatch(message, 1)) {
390
+ clearTimeout(timer);
391
+ addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
392
+ finish();
393
+ return;
394
+ }
395
+
281
396
  if (message.id === 1) {
282
397
  clearTimeout(timer);
398
+ if (!isJsonRpcResponse(message)) {
399
+ addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
400
+ finish();
401
+ return;
402
+ }
403
+
283
404
  if (message.error) {
284
405
  addIssue('error', 'initialize-error', `initialize returned error: ${message.error.message || JSON.stringify(message.error)}`);
285
406
  finish();
@@ -303,8 +424,18 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
303
424
  } else {
304
425
  finishSoon();
305
426
  }
427
+ } else if (operation && isResponseIdTypeMismatch(message, 2)) {
428
+ clearTimeout(timer);
429
+ addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
430
+ finish();
306
431
  } else if (operation && message.id === 2) {
307
432
  clearTimeout(timer);
433
+ if (!isJsonRpcResponse(message)) {
434
+ addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
435
+ finish();
436
+ return;
437
+ }
438
+
308
439
  result.operation.responded = true;
309
440
  if (message.error) {
310
441
  result.operation.error = message.error;
@@ -316,6 +447,38 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
316
447
  });
317
448
  }
318
449
 
450
+ export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
451
+ const command = commandWithArgs[0] || '';
452
+ const args = commandWithArgs.slice(1);
453
+ const basename = path.basename(command).toLowerCase();
454
+
455
+ if (!/^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(basename)) {
456
+ return '';
457
+ }
458
+
459
+ if (Object.hasOwn(env, 'PYTHONUNBUFFERED') && String(env.PYTHONUNBUFFERED) !== '') {
460
+ return '';
461
+ }
462
+
463
+ if (args.some((arg) => arg === '-u' || /^-[^-]*u/.test(arg))) {
464
+ return '';
465
+ }
466
+
467
+ return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
468
+ }
469
+
470
+ function isResponseIdTypeMismatch(message, expectedId) {
471
+ return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
472
+ }
473
+
474
+ function isJsonRpcResponse(message) {
475
+ return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
476
+ }
477
+
478
+ function hasResponsePayload(message) {
479
+ return Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error');
480
+ }
481
+
319
482
  export function validateJsonRpc(message) {
320
483
  if (!message || typeof message !== 'object' || Array.isArray(message)) {
321
484
  return 'JSON-RPC frame must be an object';
@@ -330,6 +493,10 @@ export function validateJsonRpc(message) {
330
493
  const hasResult = Object.hasOwn(message, 'result');
331
494
  const hasError = Object.hasOwn(message, 'error');
332
495
 
496
+ if (hasMethod && (hasResult || hasError)) {
497
+ return 'request/notification frame must not include result or error';
498
+ }
499
+
333
500
  if (hasId && !hasMethod && !hasResult && !hasError) {
334
501
  return 'response frame must include result or error';
335
502
  }
@@ -341,6 +508,140 @@ export function validateJsonRpc(message) {
341
508
  return '';
342
509
  }
343
510
 
511
+ function finalizeResult(result) {
512
+ result.schemaVersion = JSON_SCHEMA_VERSION;
513
+ result.staticScan ??= defaultStaticScan();
514
+ result.staticFindings ??= [];
515
+ result.ok = !result.issues.some((issue) => issue.severity === 'error');
516
+ result.checks = buildChecks(result);
517
+ return result;
518
+ }
519
+
520
+ function buildChecks(result) {
521
+ const issues = result.issues ?? [];
522
+ const repeated = Array.isArray(result.runs);
523
+
524
+ return {
525
+ initialize: repeated
526
+ ? aggregateRunCheck(result, 'initialize')
527
+ : buildInitializeCheck(result, issues),
528
+ stdout: buildIssueCheck(issues, (issue) => STDOUT_ISSUE_CODES.has(issue.code)),
529
+ jsonRpc: buildIssueCheck(issues, (issue) => JSON_RPC_ISSUE_CODES.has(issue.code)),
530
+ operation: repeated
531
+ ? aggregateRunCheck(result, 'operation')
532
+ : buildOperationCheck(result, issues),
533
+ process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
534
+ pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
535
+ staticScan: buildStaticScanCheck(result, issues),
536
+ repeat: buildRepeatCheck(result)
537
+ };
538
+ }
539
+
540
+ function buildInitializeCheck(result, issues) {
541
+ const matched = issues.filter((issue) => (
542
+ INITIALIZE_ISSUE_CODES.has(issue.code)
543
+ || (!result.initialized && STDOUT_ISSUE_CODES.has(issue.code))
544
+ || (!result.initialized && JSON_RPC_ISSUE_CODES.has(issue.code))
545
+ ));
546
+ if (matched.length) return makeCheck(statusFromIssues(matched), matched);
547
+ return makeCheck(result.initialized ? 'pass' : 'fail', []);
548
+ }
549
+
550
+ function buildOperationCheck(result, issues) {
551
+ if (!result.operation) {
552
+ return makeCheck('skipped', []);
553
+ }
554
+
555
+ if (!result.initialized) {
556
+ return makeCheck('skipped', []);
557
+ }
558
+
559
+ const matched = issues.filter((issue) => (
560
+ OPERATION_ISSUE_CODES.has(issue.code)
561
+ || (!result.operation.responded && STDOUT_ISSUE_CODES.has(issue.code))
562
+ || (!result.operation.responded && JSON_RPC_ISSUE_CODES.has(issue.code))
563
+ ));
564
+ if (matched.length) {
565
+ return makeCheck(statusFromIssues(matched), matched);
566
+ }
567
+
568
+ return makeCheck(result.operation.responded ? 'pass' : 'fail', []);
569
+ }
570
+
571
+ function buildStaticScanCheck(result, issues) {
572
+ if (!result.staticScan?.enabled) {
573
+ return makeCheck('skipped', []);
574
+ }
575
+
576
+ const matched = issues.filter((issue) => issue.code === 'static-stdout-write');
577
+ if (matched.length) {
578
+ return makeCheck(statusFromIssues(matched), matched);
579
+ }
580
+
581
+ if (result.staticFindings?.length) {
582
+ return makeCheck('warning', [{ code: 'static-stdout-write' }]);
583
+ }
584
+
585
+ return makeCheck('pass', []);
586
+ }
587
+
588
+ function buildRepeatCheck(result) {
589
+ if (!Array.isArray(result.runs)) {
590
+ return makeCheck('skipped', []);
591
+ }
592
+
593
+ const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
594
+ return {
595
+ ...makeCheck(failedRuns.length ? 'fail' : 'pass', result.issues ?? []),
596
+ runs: result.runs.length,
597
+ passedRuns: result.runs.length - failedRuns.length,
598
+ failedRuns
599
+ };
600
+ }
601
+
602
+ function aggregateRunCheck(result, checkName) {
603
+ const checks = result.runs
604
+ .map((run) => run.checks?.[checkName])
605
+ .filter(Boolean)
606
+ .filter((check) => check.status !== 'skipped');
607
+
608
+ if (!checks.length) {
609
+ return makeCheck('skipped', []);
610
+ }
611
+
612
+ const status = checks.some((check) => check.status === 'fail')
613
+ ? 'fail'
614
+ : checks.some((check) => check.status === 'warning')
615
+ ? 'warning'
616
+ : 'pass';
617
+ const issueCodes = [...new Set(checks.flatMap((check) => check.issueCodes))].sort();
618
+ return { status, issueCodes };
619
+ }
620
+
621
+ function buildIssueCheck(issues, predicate) {
622
+ const matched = issues.filter(predicate);
623
+ return makeCheck(matched.length ? statusFromIssues(matched) : 'pass', matched);
624
+ }
625
+
626
+ function statusFromIssues(issues) {
627
+ return issues.some((issue) => issue.severity === 'error') ? 'fail' : 'warning';
628
+ }
629
+
630
+ function makeCheck(status, issues) {
631
+ return {
632
+ status,
633
+ issueCodes: [...new Set(issues.map((issue) => issue.code))].sort()
634
+ };
635
+ }
636
+
637
+ function defaultStaticScan() {
638
+ return {
639
+ enabled: false,
640
+ path: '',
641
+ failOnFindings: false
642
+ };
643
+ }
644
+
344
645
  export function scanSource(root) {
345
646
  const findings = [];
346
647
  const absoluteRoot = path.resolve(root);
@@ -414,6 +715,10 @@ function listSourceFiles(root) {
414
715
  }
415
716
 
416
717
  function formatTextResult(result) {
718
+ if (Array.isArray(result.runs)) {
719
+ return formatRepeatedTextResult(result);
720
+ }
721
+
417
722
  const status = result.ok ? 'PASS' : 'FAIL';
418
723
  const invalidFrames = result.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
419
724
  const stderrLines = result.stderr ? result.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
@@ -447,6 +752,36 @@ function formatTextResult(result) {
447
752
  return lines.join('\n');
448
753
  }
449
754
 
755
+ function formatRepeatedTextResult(result) {
756
+ const status = result.ok ? 'PASS' : 'FAIL';
757
+ const passedRuns = result.runs.filter((run) => run.ok).length;
758
+ const lines = [
759
+ `${status} MCP stdio guard`,
760
+ `runs: ${passedRuns}/${result.runs.length} passed`
761
+ ];
762
+
763
+ for (const run of result.runs) {
764
+ const runStatus = run.ok ? 'PASS' : 'FAIL';
765
+ const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
766
+ const stderrLines = run.stderr ? run.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
767
+ lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines`);
768
+ }
769
+
770
+ if (result.staticFindings.length) {
771
+ lines.push(`static findings: ${result.staticFindings.length}`);
772
+ for (const finding of result.staticFindings.slice(0, 10)) {
773
+ lines.push(`[warning] ${finding.file}:${finding.line} ${finding.message}`);
774
+ }
775
+ }
776
+
777
+ for (const issue of result.issues) {
778
+ const prefix = issue.run ? `run ${issue.run} ` : '';
779
+ lines.push(`[${issue.severity}] ${prefix}${issue.code}: ${issue.message}`);
780
+ }
781
+
782
+ return lines.join('\n');
783
+ }
784
+
450
785
  function readOptionValue(argv, index, option) {
451
786
  const value = argv[index + 1];
452
787
  if (!value || value.startsWith('--')) {
@@ -471,12 +806,15 @@ function quote(value) {
471
806
  function helpText() {
472
807
  return `mcp-stdio-guard validates MCP stdio servers.
473
808
 
809
+ MCP stdio output is expected as newline-delimited JSON-RPC on stdout.
810
+
474
811
  Usage:
475
812
  mcp-stdio-guard [options] -- <command> [args...]
476
813
 
477
814
  Options:
478
815
  --protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
479
816
  --timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
817
+ --repeat <count> run the guard multiple times, default 1
480
818
  --scan <path> scan source for risky stdout writes
481
819
  --fail-on-static fail when --scan finds risky stdout writes
482
820
  --request <method> send one MCP request after initialize, e.g. tools/list