mcp-stdio-guard 0.2.0 → 0.4.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 +243 -8
  2. package/package.json +1 -1
  3. package/src/index.js +2265 -90
package/src/index.js CHANGED
@@ -1,20 +1,78 @@
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';
4
5
 
5
6
  function loadVersion() {
6
7
  try {
7
8
  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
+ return typeof packageJson.version === 'string' ? packageJson.version : '0.4.0';
9
10
  } catch {
10
- return '0.2.0';
11
+ return '0.4.0';
11
12
  }
12
13
  }
13
14
 
14
15
  const DEFAULT_PROTOCOL = '2025-11-25';
15
16
  const DEFAULT_TIMEOUT = 5000;
17
+ const DEFAULT_PROFILE = 'custom';
16
18
  const VERSION = loadVersion();
17
19
  const JSON_SCHEMA_VERSION = 1;
20
+ const REDACTED = '<redacted>';
21
+ const GUARD_PROFILES = Object.freeze({
22
+ custom: {
23
+ description: 'preserve explicit CLI flags and legacy defaults'
24
+ },
25
+ smoke: {
26
+ description: 'initialize only; skip advertised capability list probes'
27
+ },
28
+ registry: {
29
+ description: 'initialize, advertised list probes, fingerprint, and repeat consistency'
30
+ },
31
+ ci: {
32
+ description: 'stable JSON output with static findings treated as failures when scanned'
33
+ },
34
+ strict: {
35
+ description: 'deep deterministic checks; reserved for opt-in adversarial probes'
36
+ }
37
+ });
38
+ const GUARD_PROFILE_NAMES = Object.keys(GUARD_PROFILES);
39
+ const VERSION_PROBE_CACHE = new Map();
40
+ const NODE_OPTIONS_WITH_VALUES = new Set([
41
+ '--conditions',
42
+ '--cpu-prof-dir',
43
+ '--diagnostic-dir',
44
+ '--experimental-loader',
45
+ '--heapsnapshot-near-heap-limit',
46
+ '--import',
47
+ '--inspect-port',
48
+ '--loader',
49
+ '--max-old-space-size',
50
+ '--openssl-config',
51
+ '--perf-basic-prof-only-functions',
52
+ '--prof-process',
53
+ '--redirect-warnings',
54
+ '--require',
55
+ '--test-reporter',
56
+ '--test-reporter-destination',
57
+ '--title',
58
+ '--trace-event-categories',
59
+ '--trace-event-file-pattern',
60
+ '-C',
61
+ '-r'
62
+ ]);
63
+ const NODE_EVAL_OPTIONS = new Set(['--eval', '--print', '-e', '-p']);
64
+
65
+ export const ISSUE_CLASSES = Object.freeze({
66
+ INSTALL_RUNTIME: 'installRuntime',
67
+ STDIO_TRANSPORT: 'stdioTransport',
68
+ MCP_PROTOCOL: 'mcpProtocol'
69
+ });
70
+
71
+ const ISSUE_CLASS_NAMES = [
72
+ ISSUE_CLASSES.INSTALL_RUNTIME,
73
+ ISSUE_CLASSES.STDIO_TRANSPORT,
74
+ ISSUE_CLASSES.MCP_PROTOCOL
75
+ ];
18
76
 
19
77
  const STDOUT_ISSUE_CODES = new Set([
20
78
  'stdout-empty-line',
@@ -25,12 +83,38 @@ const STDOUT_ISSUE_CODES = new Set([
25
83
  'stdout-without-newline'
26
84
  ]);
27
85
  const JSON_RPC_ISSUE_CODES = new Set([
86
+ 'notification-response',
87
+ 'response-id-mismatch',
28
88
  'response-id-type-mismatch',
29
89
  'stdout-invalid-json-rpc',
30
90
  'stdout-unexpected-request-id'
31
91
  ]);
92
+ const CAPABILITY_DEFINITIONS = Object.freeze([
93
+ { name: 'tools', method: 'tools/list' },
94
+ { name: 'resources', method: 'resources/list' },
95
+ { name: 'prompts', method: 'prompts/list' }
96
+ ]);
97
+ const CAPABILITY_ISSUE_CODES = new Set([
98
+ 'capability-list-error',
99
+ 'capability-list-missing-response',
100
+ 'capability-list-timeout',
101
+ 'capability-list-unsupported'
102
+ ]);
103
+ const REPEAT_DRIFT_ISSUE_CODES = new Set([
104
+ 'repeat-capability-drift',
105
+ 'repeat-list-shape-drift',
106
+ 'repeat-protocol-drift',
107
+ 'repeat-tool-drift'
108
+ ]);
32
109
  const INITIALIZE_ISSUE_CODES = new Set([
33
110
  'initialize-error',
111
+ 'initialize-invalid-capabilities',
112
+ 'initialize-invalid-protocol-version',
113
+ 'initialize-invalid-result',
114
+ 'initialize-invalid-server-info',
115
+ 'initialize-missing-capabilities',
116
+ 'initialize-missing-protocol-version',
117
+ 'initialize-missing-server-info',
34
118
  'initialize-timeout',
35
119
  'server-exited',
36
120
  'spawn-failed'
@@ -40,11 +124,64 @@ const OPERATION_ISSUE_CODES = new Set([
40
124
  'operation-missing-response',
41
125
  'operation-timeout'
42
126
  ]);
127
+ const TOOL_SCHEMA_ISSUE_CODES = new Set([
128
+ 'tool-description-missing',
129
+ 'tool-input-schema-invalid',
130
+ 'tool-input-schema-required-missing',
131
+ 'tool-name-duplicate',
132
+ 'tool-name-invalid',
133
+ 'tools-list-invalid-result'
134
+ ]);
135
+ const JSON_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
43
136
  const PROCESS_ISSUE_CODES = new Set([
44
137
  'server-crashed',
45
138
  'server-exited',
46
139
  'spawn-failed'
47
140
  ]);
141
+ const ISSUE_CLASS_BY_CODE = new Map([
142
+ ['initialize-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
143
+ ['operation-missing-response', ISSUE_CLASSES.INSTALL_RUNTIME],
144
+ ['operation-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
145
+ ['python-buffered-stdio', ISSUE_CLASSES.INSTALL_RUNTIME],
146
+ ['server-crashed', ISSUE_CLASSES.INSTALL_RUNTIME],
147
+ ['server-exited', ISSUE_CLASSES.INSTALL_RUNTIME],
148
+ ['spawn-failed', ISSUE_CLASSES.INSTALL_RUNTIME],
149
+
150
+ ['static-stdout-write', ISSUE_CLASSES.STDIO_TRANSPORT],
151
+ ['stdout-content-length-framing', ISSUE_CLASSES.STDIO_TRANSPORT],
152
+ ['stdout-empty-line', ISSUE_CLASSES.STDIO_TRANSPORT],
153
+ ['stdout-non-json', ISSUE_CLASSES.STDIO_TRANSPORT],
154
+ ['stdout-without-newline', ISSUE_CLASSES.STDIO_TRANSPORT],
155
+
156
+ ['initialize-error', ISSUE_CLASSES.MCP_PROTOCOL],
157
+ ['initialize-invalid-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
158
+ ['initialize-invalid-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
159
+ ['initialize-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
160
+ ['initialize-invalid-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
161
+ ['initialize-missing-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
162
+ ['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
163
+ ['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
164
+ ['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
165
+ ['capability-list-error', ISSUE_CLASSES.MCP_PROTOCOL],
166
+ ['capability-list-missing-response', ISSUE_CLASSES.MCP_PROTOCOL],
167
+ ['capability-list-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
168
+ ['capability-list-unsupported', ISSUE_CLASSES.MCP_PROTOCOL],
169
+ ['repeat-capability-drift', ISSUE_CLASSES.MCP_PROTOCOL],
170
+ ['repeat-list-shape-drift', ISSUE_CLASSES.MCP_PROTOCOL],
171
+ ['repeat-protocol-drift', ISSUE_CLASSES.MCP_PROTOCOL],
172
+ ['repeat-tool-drift', ISSUE_CLASSES.MCP_PROTOCOL],
173
+ ['tool-description-missing', ISSUE_CLASSES.MCP_PROTOCOL],
174
+ ['tool-input-schema-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
175
+ ['tool-input-schema-required-missing', ISSUE_CLASSES.MCP_PROTOCOL],
176
+ ['tool-name-duplicate', ISSUE_CLASSES.MCP_PROTOCOL],
177
+ ['tool-name-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
178
+ ['tools-list-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
179
+ ['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
180
+ ['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
181
+ ['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
182
+ ['stdout-invalid-json-rpc', ISSUE_CLASSES.MCP_PROTOCOL],
183
+ ['stdout-unexpected-request-id', ISSUE_CLASSES.MCP_PROTOCOL]
184
+ ]);
48
185
 
49
186
  export async function runCli(argv) {
50
187
  const options = parseArgs(argv);
@@ -64,13 +201,18 @@ export async function runCli(argv) {
64
201
  }
65
202
 
66
203
  const guardOptions = {
204
+ config: options.config,
205
+ profile: options.profile,
67
206
  protocol: options.protocol,
68
207
  timeoutMs: options.timeoutMs,
69
208
  cwd: options.cwd,
70
- operation: options.requestMethod
209
+ env: options.env,
210
+ probeCapabilities: options.probeCapabilities,
211
+ operations: options.operations,
212
+ operation: options.operations.length === 1
71
213
  ? {
72
- method: options.requestMethod,
73
- params: options.requestParams
214
+ method: options.operations[0].method,
215
+ params: options.operations[0].params
74
216
  }
75
217
  : null
76
218
  };
@@ -85,6 +227,9 @@ export async function runCli(argv) {
85
227
  path: options.scanPath,
86
228
  failOnFindings: options.failOnStatic
87
229
  };
230
+ if (result.fingerprint) {
231
+ result.fingerprint.staticScan = result.staticScan;
232
+ }
88
233
  result.staticFindings = scanSource(options.scanPath);
89
234
  if (options.failOnStatic) {
90
235
  for (const finding of result.staticFindings) {
@@ -113,8 +258,15 @@ export async function runCli(argv) {
113
258
  export function parseArgs(argv) {
114
259
  const options = {
115
260
  command: [],
261
+ configPath: '',
262
+ config: defaultConfigMetadata(),
263
+ env: {},
264
+ operations: [],
265
+ configOperations: [],
116
266
  protocol: DEFAULT_PROTOCOL,
117
267
  timeoutMs: DEFAULT_TIMEOUT,
268
+ profile: DEFAULT_PROFILE,
269
+ probeCapabilities: true,
118
270
  scanPath: '',
119
271
  failOnStatic: false,
120
272
  requestMethod: '',
@@ -125,12 +277,14 @@ export function parseArgs(argv) {
125
277
  version: false,
126
278
  cwd: process.cwd()
127
279
  };
280
+ const specifiedOptions = new Set();
128
281
 
129
282
  for (let index = 0; index < argv.length; index += 1) {
130
283
  const arg = argv[index];
131
284
 
132
285
  if (arg === '--') {
133
286
  options.command = argv.slice(index + 1);
287
+ specifiedOptions.add('command');
134
288
  break;
135
289
  }
136
290
 
@@ -140,34 +294,61 @@ export function parseArgs(argv) {
140
294
  options.version = true;
141
295
  } else if (arg === '--json') {
142
296
  options.json = true;
297
+ specifiedOptions.add('json');
298
+ } else if (arg === '--config') {
299
+ options.configPath = path.resolve(readOptionValue(argv, index, arg));
300
+ specifiedOptions.add('configPath');
301
+ index += 1;
302
+ } else if (arg === '--profile') {
303
+ options.profile = readOptionValue(argv, index, arg);
304
+ specifiedOptions.add('profile');
305
+ index += 1;
143
306
  } else if (arg === '--fail-on-static') {
144
307
  options.failOnStatic = true;
308
+ specifiedOptions.add('failOnStatic');
145
309
  } else if (arg === '--request') {
146
310
  options.requestMethod = readOptionValue(argv, index, arg);
311
+ specifiedOptions.add('requestMethod');
147
312
  index += 1;
148
313
  } else if (arg === '--params') {
149
314
  options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
315
+ specifiedOptions.add('requestParams');
150
316
  index += 1;
151
317
  } else if (arg === '--protocol') {
152
318
  options.protocol = readOptionValue(argv, index, arg);
319
+ specifiedOptions.add('protocol');
153
320
  index += 1;
154
321
  } else if (arg === '--timeout') {
155
322
  options.timeoutMs = Number(readOptionValue(argv, index, arg));
323
+ specifiedOptions.add('timeoutMs');
156
324
  index += 1;
157
325
  } else if (arg === '--repeat') {
158
326
  options.repeat = Number(readOptionValue(argv, index, arg));
327
+ specifiedOptions.add('repeat');
159
328
  index += 1;
160
329
  } else if (arg === '--scan') {
161
330
  options.scanPath = path.resolve(readOptionValue(argv, index, arg));
331
+ specifiedOptions.add('scanPath');
162
332
  index += 1;
163
333
  } else if (arg === '--cwd') {
164
334
  options.cwd = path.resolve(readOptionValue(argv, index, arg));
335
+ specifiedOptions.add('cwd');
165
336
  index += 1;
166
337
  } else {
167
338
  throw new Error(`Unknown option before --: ${arg}`);
168
339
  }
169
340
  }
170
341
 
342
+ if (options.help || options.version) {
343
+ return options;
344
+ }
345
+
346
+ if (options.configPath) {
347
+ applyConfigFile(options, loadConfigFile(options.configPath), specifiedOptions);
348
+ }
349
+ applyProfileDefaults(options, specifiedOptions);
350
+ options.operations = buildConfiguredOperations(options);
351
+
171
352
  if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
172
353
  throw new Error('--timeout must be an integer >= 100');
173
354
  }
@@ -183,6 +364,275 @@ export function parseArgs(argv) {
183
364
  return options;
184
365
  }
185
366
 
367
+ function applyProfileDefaults(options, specifiedOptions) {
368
+ if (!GUARD_PROFILE_NAMES.includes(options.profile)) {
369
+ throw new Error(`--profile must be one of: ${GUARD_PROFILE_NAMES.join(', ')}`);
370
+ }
371
+
372
+ if (options.profile === 'smoke') {
373
+ options.probeCapabilities = false;
374
+ return options;
375
+ }
376
+
377
+ options.probeCapabilities = true;
378
+
379
+ if (options.profile === 'registry' && !specifiedOptions.has('repeat')) {
380
+ options.repeat = 2;
381
+ }
382
+
383
+ if (options.profile === 'ci') {
384
+ options.json = true;
385
+ options.failOnStatic = true;
386
+ }
387
+
388
+ if (options.profile === 'strict') {
389
+ options.json = true;
390
+ options.failOnStatic = true;
391
+ if (!specifiedOptions.has('repeat')) {
392
+ options.repeat = 2;
393
+ }
394
+ }
395
+
396
+ return options;
397
+ }
398
+
399
+ function loadConfigFile(configPath) {
400
+ let raw;
401
+ try {
402
+ raw = fs.readFileSync(configPath, 'utf8');
403
+ } catch (error) {
404
+ throw new Error(`Failed to read --config ${configPath}: ${error.message}`);
405
+ }
406
+
407
+ try {
408
+ const parsed = JSON.parse(raw);
409
+ if (!isObjectRecord(parsed)) {
410
+ throw new Error('top-level value must be an object');
411
+ }
412
+ return parsed;
413
+ } catch (error) {
414
+ throw new Error(`--config ${configPath} must be valid JSON: ${error.message}`);
415
+ }
416
+ }
417
+
418
+ function applyConfigFile(options, config, specifiedOptions) {
419
+ const configDir = path.dirname(options.configPath);
420
+ const command = normalizeConfigCommand(config);
421
+ const env = normalizeConfigEnv(config);
422
+ const requests = normalizeConfigRequests(config);
423
+ const safeToolCalls = normalizeSafeToolCalls(config);
424
+ const usesConfigCommand = command.length > 0 && !specifiedOptions.has('command');
425
+ const usesConfigCwd = typeof config.cwd === 'string' && !specifiedOptions.has('cwd');
426
+ const usesConfigOperations = !specifiedOptions.has('requestMethod') && !specifiedOptions.has('requestParams');
427
+
428
+ if (usesConfigCommand) {
429
+ options.command = command;
430
+ }
431
+
432
+ if (usesConfigCwd) {
433
+ options.cwd = resolveConfigPath(config.cwd, configDir);
434
+ } else if (config.cwd !== undefined && typeof config.cwd !== 'string') {
435
+ throw new Error('--config cwd must be a string');
436
+ }
437
+
438
+ if (config.protocol !== undefined && !specifiedOptions.has('protocol')) {
439
+ if (typeof config.protocol !== 'string' || !config.protocol) {
440
+ throw new Error('--config protocol must be a non-empty string');
441
+ }
442
+ options.protocol = config.protocol;
443
+ }
444
+
445
+ const timeoutField = Object.hasOwn(config, 'timeoutMs') ? 'timeoutMs' : 'timeout';
446
+ const timeoutValue = config[timeoutField];
447
+ if (timeoutValue !== undefined && !specifiedOptions.has('timeoutMs')) {
448
+ options.timeoutMs = normalizeConfigInteger(timeoutValue, timeoutField, 100);
449
+ }
450
+
451
+ if (config.repeat !== undefined && !specifiedOptions.has('repeat')) {
452
+ options.repeat = normalizeConfigInteger(config.repeat, 'repeat', 1);
453
+ }
454
+
455
+ if (config.profile !== undefined && !specifiedOptions.has('profile')) {
456
+ if (typeof config.profile !== 'string') {
457
+ throw new Error('--config profile must be a string');
458
+ }
459
+ options.profile = config.profile;
460
+ }
461
+
462
+ if (config.json !== undefined && !specifiedOptions.has('json')) {
463
+ if (typeof config.json !== 'boolean') {
464
+ throw new Error('--config json must be a boolean');
465
+ }
466
+ options.json = config.json;
467
+ }
468
+
469
+ const scanPath = config.scanPath ?? config.scan;
470
+ if (scanPath !== undefined && !specifiedOptions.has('scanPath')) {
471
+ if (typeof scanPath !== 'string') {
472
+ throw new Error('--config scan must be a string path');
473
+ }
474
+ options.scanPath = resolveConfigPath(scanPath, configDir);
475
+ }
476
+
477
+ if (config.failOnStatic !== undefined && !specifiedOptions.has('failOnStatic')) {
478
+ if (typeof config.failOnStatic !== 'boolean') {
479
+ throw new Error('--config failOnStatic must be a boolean');
480
+ }
481
+ options.failOnStatic = config.failOnStatic;
482
+ }
483
+
484
+ if (Object.keys(env).length) {
485
+ options.env = { ...env, ...options.env };
486
+ }
487
+
488
+ if (usesConfigOperations) {
489
+ options.configOperations = [...requests, ...safeToolCalls];
490
+ }
491
+
492
+ options.config = {
493
+ enabled: true,
494
+ path: options.configPath,
495
+ resolvedPath: options.configPath,
496
+ checks: {
497
+ command: usesConfigCommand,
498
+ cwd: usesConfigCwd,
499
+ envNames: Object.keys(env).sort(),
500
+ requests: usesConfigOperations ? requests.map((request) => request.method) : [],
501
+ safeToolCalls: usesConfigOperations ? safeToolCalls.map((request) => request.safeToolCallName) : []
502
+ }
503
+ };
504
+ }
505
+
506
+ function normalizeConfigCommand(config) {
507
+ if (config.command === undefined && config.args === undefined) return [];
508
+
509
+ if (Array.isArray(config.command)) {
510
+ if (!config.command.every((entry) => typeof entry === 'string' && entry)) {
511
+ throw new Error('--config command array entries must be non-empty strings');
512
+ }
513
+ if (config.args !== undefined) {
514
+ throw new Error('--config args cannot be used when command is an array');
515
+ }
516
+ return config.command;
517
+ }
518
+
519
+ if (typeof config.command === 'string' && config.command) {
520
+ const args = config.args ?? [];
521
+ if (!Array.isArray(args) || !args.every((entry) => typeof entry === 'string')) {
522
+ throw new Error('--config args must be an array of strings');
523
+ }
524
+ return [config.command, ...args];
525
+ }
526
+
527
+ throw new Error('--config command must be a non-empty string or array of strings');
528
+ }
529
+
530
+ function normalizeConfigEnv(config) {
531
+ if (config.env === undefined) return {};
532
+ if (!isObjectRecord(config.env)) {
533
+ throw new Error('--config env must be an object');
534
+ }
535
+
536
+ return Object.fromEntries(Object.entries(config.env).map(([name, value]) => {
537
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
538
+ throw new Error(`--config env contains invalid variable name: ${name}`);
539
+ }
540
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
541
+ throw new Error(`--config env.${name} must be a string, number, or boolean`);
542
+ }
543
+ return [name, String(value)];
544
+ }));
545
+ }
546
+
547
+ function normalizeConfigInteger(value, field, minimum) {
548
+ const normalized = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
549
+ if (!Number.isInteger(normalized) || normalized < minimum) {
550
+ throw new Error(`--config ${field} must be an integer >= ${minimum}`);
551
+ }
552
+ return normalized;
553
+ }
554
+
555
+ function normalizeConfigRequests(config) {
556
+ const values = [];
557
+ if (config.request !== undefined) {
558
+ values.push(config.request);
559
+ }
560
+ if (config.requests !== undefined) {
561
+ if (!Array.isArray(config.requests)) {
562
+ throw new Error('--config requests must be an array');
563
+ }
564
+ values.push(...config.requests);
565
+ }
566
+
567
+ return values.map((request, index) => {
568
+ if (!isObjectRecord(request)) {
569
+ throw new Error(`--config requests[${index}] must be an object`);
570
+ }
571
+ if (typeof request.method !== 'string' || !request.method) {
572
+ throw new Error(`--config requests[${index}].method must be a non-empty string`);
573
+ }
574
+ return {
575
+ source: 'config-request',
576
+ method: request.method,
577
+ params: request.params
578
+ };
579
+ });
580
+ }
581
+
582
+ function normalizeSafeToolCalls(config) {
583
+ if (config.safeToolCalls === undefined) return [];
584
+ if (!Array.isArray(config.safeToolCalls)) {
585
+ throw new Error('--config safeToolCalls must be an array');
586
+ }
587
+
588
+ return config.safeToolCalls.map((call, index) => {
589
+ if (!isObjectRecord(call)) {
590
+ throw new Error(`--config safeToolCalls[${index}] must be an object`);
591
+ }
592
+ if (typeof call.name !== 'string' || !call.name) {
593
+ throw new Error(`--config safeToolCalls[${index}].name must be a non-empty string`);
594
+ }
595
+ const argumentsValue = call.arguments ?? {};
596
+ if (!isObjectRecord(argumentsValue)) {
597
+ throw new Error(`--config safeToolCalls[${index}].arguments must be an object`);
598
+ }
599
+ return {
600
+ source: 'safe-tool-call',
601
+ method: 'tools/call',
602
+ params: {
603
+ name: call.name,
604
+ arguments: argumentsValue
605
+ },
606
+ safeToolCallName: call.name
607
+ };
608
+ });
609
+ }
610
+
611
+ function buildConfiguredOperations(options) {
612
+ if (options.requestMethod) {
613
+ return [{
614
+ source: 'cli-request',
615
+ method: options.requestMethod,
616
+ params: options.requestParams
617
+ }];
618
+ }
619
+
620
+ return options.configOperations;
621
+ }
622
+
623
+ function normalizeGuardOperations(operations) {
624
+ return operations.map((operation) => ({
625
+ source: operation.source ?? 'request',
626
+ method: operation.method,
627
+ params: operation.params,
628
+ safeToolCallName: operation.safeToolCallName ?? ''
629
+ }));
630
+ }
631
+
632
+ function resolveConfigPath(value, configDir) {
633
+ return path.resolve(configDir, value);
634
+ }
635
+
186
636
  export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
187
637
  const startedAt = Date.now();
188
638
  const repeat = options.repeat ?? 1;
@@ -193,8 +643,10 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
193
643
  throw new Error('repeat must be an integer >= 1');
194
644
  }
195
645
 
646
+ const singleRunOptions = { ...options, repeat: 1 };
647
+
196
648
  for (let index = 1; index <= repeat; index += 1) {
197
- const run = await guardStdioServer(commandWithArgs, options);
649
+ const run = await guardStdioServer(commandWithArgs, singleRunOptions);
198
650
  run.run = index;
199
651
  runs.push(run);
200
652
  for (const issue of run.issues) {
@@ -202,19 +654,33 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
202
654
  }
203
655
  }
204
656
 
205
- return finalizeResult({
657
+ const drift = buildRepeatDrift(runs);
658
+ for (const issue of repeatDriftIssues(drift)) {
659
+ issues.push(issue);
660
+ }
661
+
662
+ const durationMs = Date.now() - startedAt;
663
+ const result = {
206
664
  schemaVersion: JSON_SCHEMA_VERSION,
207
665
  ok: !issues.some((issue) => issue.severity === 'error'),
666
+ profile: options.profile ?? DEFAULT_PROFILE,
208
667
  command: commandWithArgs,
668
+ config: options.config ?? defaultConfigMetadata(),
209
669
  protocol: options.protocol ?? DEFAULT_PROTOCOL,
210
670
  repeat,
211
671
  runs,
212
672
  issues,
213
673
  checks: {},
674
+ capabilityProbes: options.probeCapabilities ?? true,
675
+ drift,
214
676
  staticScan: defaultStaticScan(),
215
677
  staticFindings: [],
216
- durationMs: Date.now() - startedAt
217
- });
678
+ toolSchema: aggregateRunToolSchemaValidation(runs),
679
+ durationMs,
680
+ fingerprint: createFingerprint(commandWithArgs, options)
681
+ };
682
+ result.fingerprint.timings.totalMs = durationMs;
683
+ return finalizeResult(result);
218
684
  }
219
685
 
220
686
  export async function guardStdioServer(commandWithArgs, options = {}) {
@@ -223,7 +689,8 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
223
689
  const args = commandWithArgs.slice(1);
224
690
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
225
691
  const protocol = options.protocol ?? DEFAULT_PROTOCOL;
226
- const operation = options.operation || null;
692
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
693
+ const probeCapabilities = options.probeCapabilities ?? true;
227
694
  const env = { ...process.env, ...(options.env ?? {}) };
228
695
  const issues = [];
229
696
  const frames = [];
@@ -231,41 +698,80 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
231
698
  let stdoutBuffer = '';
232
699
  let initialized = false;
233
700
  let endedByGuard = false;
701
+ let initializeResponseAt = 0;
702
+ let currentRequest = null;
703
+ let requestQueue = [];
704
+ let nextRequestId = 2;
234
705
  let timer;
235
706
  let child;
236
707
 
237
708
  const result = {
238
709
  schemaVersion: JSON_SCHEMA_VERSION,
239
710
  ok: false,
711
+ profile: options.profile ?? DEFAULT_PROFILE,
240
712
  command: commandWithArgs,
713
+ config: options.config ?? defaultConfigMetadata(),
241
714
  protocol,
242
715
  negotiatedProtocol: '',
243
716
  initialized: false,
244
- operation: operation
717
+ operation: operations.length === 1
245
718
  ? {
246
- method: operation.method,
719
+ method: operations[0].method,
720
+ source: operations[0].source,
721
+ safeToolCallName: operations[0].safeToolCallName,
247
722
  responded: false,
248
723
  error: null
249
724
  }
250
725
  : null,
726
+ operations: operations.map((operation, index) => ({
727
+ index,
728
+ source: operation.source,
729
+ method: operation.method,
730
+ safeToolCallName: operation.safeToolCallName,
731
+ responded: false,
732
+ error: null
733
+ })),
251
734
  frames,
252
735
  issues,
253
736
  checks: {},
737
+ capabilityProbes: probeCapabilities,
738
+ capabilityKeys: [],
739
+ capabilityChecks: defaultCapabilityChecks(),
254
740
  stderr: '',
741
+ process: defaultProcessInfo(timeoutMs),
255
742
  staticScan: defaultStaticScan(),
256
743
  staticFindings: [],
257
- durationMs: 0
744
+ toolSchema: defaultToolSchemaValidation(),
745
+ durationMs: 0,
746
+ fingerprint: createFingerprint(commandWithArgs, {
747
+ protocol,
748
+ timeoutMs,
749
+ cwd: options.cwd,
750
+ config: options.config,
751
+ profile: options.profile,
752
+ probeCapabilities,
753
+ operations,
754
+ env: options.env
755
+ })
258
756
  };
259
757
 
260
758
  return new Promise((resolve) => {
261
- function addIssue(severity, code, message) {
262
- issues.push({ severity, code, message });
759
+ function addIssue(severity, code, message, details = {}) {
760
+ issues.push({ ...details, severity, code, message });
263
761
  }
264
762
 
265
- function armTimeout(code, message) {
763
+ function armTimeout(code, message, timeoutPhase, details = {}) {
266
764
  clearTimeout(timer);
765
+ result.process.phase = timeoutPhase;
267
766
  timer = setTimeout(() => {
268
- addIssue('error', code, message);
767
+ result.process.timedOut = true;
768
+ result.process.timeoutCode = code;
769
+ result.process.timeoutMs = timeoutMs;
770
+ result.process.outcome = 'timeout';
771
+ addIssue('error', code, message, {
772
+ ...timeoutIssueDetails(code, timeoutMs, timeoutPhase),
773
+ ...details
774
+ });
269
775
  finish();
270
776
  }, timeoutMs);
271
777
  }
@@ -281,8 +787,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
281
787
  result.durationMs = Date.now() - startedAt;
282
788
  result.stderr = Buffer.concat(stderrChunks).toString('utf8');
283
789
  result.initialized = initialized;
790
+ result.fingerprint.timings.startupMs = initializeResponseAt ? initializeResponseAt - startedAt : null;
791
+ result.fingerprint.timings.totalMs = result.durationMs;
792
+ const willTerminate = child && result.process.started && !child.killed && child.exitCode === null;
793
+ if (willTerminate) {
794
+ result.process.killedByGuard = true;
795
+ result.process.killSignal = 'SIGTERM';
796
+ result.process.killReason = result.process.timedOut ? 'timeout' : 'guard-finished';
797
+ if (!result.process.outcome || result.process.outcome === 'running') {
798
+ result.process.outcome = 'guard-terminated';
799
+ }
800
+ }
284
801
  finalizeResult(result);
285
- if (child && !child.killed && child.exitCode === null) {
802
+ if (willTerminate) {
286
803
  endedByGuard = true;
287
804
  child.kill('SIGTERM');
288
805
  }
@@ -290,9 +807,166 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
290
807
  }
291
808
 
292
809
  function send(message) {
810
+ if (!child?.stdin?.writable) return;
293
811
  child.stdin.write(`${JSON.stringify(message)}\n`);
294
812
  }
295
813
 
814
+ function enqueueRequest(request) {
815
+ requestQueue.push({
816
+ ...request,
817
+ id: nextRequestId
818
+ });
819
+ nextRequestId += 1;
820
+ }
821
+
822
+ function startNextRequest() {
823
+ if (result.durationMs || currentRequest) return;
824
+ currentRequest = requestQueue.shift() || null;
825
+ if (!currentRequest) {
826
+ result.process.phase = 'post-initialize';
827
+ finishSoon();
828
+ return;
829
+ }
830
+
831
+ result.process.phase = 'operation';
832
+ const request = {
833
+ jsonrpc: '2.0',
834
+ id: currentRequest.id,
835
+ method: currentRequest.method
836
+ };
837
+ if (currentRequest.params !== undefined) {
838
+ request.params = currentRequest.params;
839
+ }
840
+ send(request);
841
+ armTimeout(
842
+ currentRequest.timeoutCode,
843
+ currentRequest.timeoutMessage,
844
+ 'operation',
845
+ currentRequest.timeoutDetails
846
+ );
847
+ }
848
+
849
+ function configureCapabilityChecks(capabilities) {
850
+ result.capabilityKeys = capabilityKeys(capabilities);
851
+ for (const definition of CAPABILITY_DEFINITIONS) {
852
+ const check = result.capabilityChecks[definition.name];
853
+ check.advertised = result.capabilityKeys.includes(definition.name);
854
+ }
855
+ }
856
+
857
+ function enqueuePostInitializeRequests() {
858
+ const operationCapabilities = new Set(operations.map((operation) => capabilityNameForMethod(operation.method)).filter(Boolean));
859
+ for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
860
+ const operation = operations[operationIndex];
861
+ const operationCapability = capabilityNameForMethod(operation.method);
862
+ enqueueRequest({
863
+ kind: 'operation',
864
+ operationIndex,
865
+ capability: result.capabilityChecks[operationCapability]?.advertised ? operationCapability : '',
866
+ method: operation.method,
867
+ params: operation.params,
868
+ timeoutCode: 'operation-timeout',
869
+ timeoutMessage: `no ${operation.method} response within ${timeoutMs}ms`,
870
+ timeoutDetails: {}
871
+ });
872
+ }
873
+
874
+ if (!probeCapabilities) return;
875
+
876
+ for (const definition of CAPABILITY_DEFINITIONS) {
877
+ const check = result.capabilityChecks[definition.name];
878
+ if (!check.advertised || operationCapabilities.has(definition.name)) continue;
879
+ enqueueRequest({
880
+ kind: 'capability',
881
+ capability: definition.name,
882
+ method: definition.method,
883
+ timeoutCode: 'capability-list-timeout',
884
+ timeoutMessage: `${definition.method} did not receive a response for advertised ${definition.name} capability within ${timeoutMs}ms`,
885
+ timeoutDetails: {
886
+ capability: definition.name,
887
+ method: definition.method,
888
+ detailCode: 'capability-request-timeout'
889
+ }
890
+ });
891
+ }
892
+ }
893
+
894
+ function handleCurrentRequestResponse(message) {
895
+ clearTimeout(timer);
896
+ const request = currentRequest;
897
+ if (!isJsonRpcResponse(message)) {
898
+ addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id ${request.id} is not a ${request.method} response`);
899
+ finish();
900
+ return;
901
+ }
902
+
903
+ if (request.kind === 'operation') {
904
+ const operationResult = result.operations[request.operationIndex];
905
+ operationResult.responded = true;
906
+ if (result.operation && request.operationIndex === 0) {
907
+ result.operation.responded = true;
908
+ }
909
+ if (message.error) {
910
+ operationResult.error = message.error;
911
+ if (result.operation && request.operationIndex === 0) {
912
+ result.operation.error = message.error;
913
+ }
914
+ addIssue('warning', 'operation-error', `${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
915
+ }
916
+ }
917
+
918
+ if (!message.error && request.method === 'tools/list') {
919
+ const validation = validateToolsListResult(message.result);
920
+ result.toolSchema = validation.summary;
921
+ for (const issue of validation.issues) {
922
+ addIssue(issue.severity, issue.code, issue.message, issue.details);
923
+ }
924
+ }
925
+
926
+ if (request.capability) {
927
+ recordCapabilityListShape(request, message.result);
928
+ handleCapabilityResponse(request, message);
929
+ }
930
+
931
+ currentRequest = null;
932
+ startNextRequest();
933
+ }
934
+
935
+ function handleCapabilityResponse(request, message) {
936
+ const check = result.capabilityChecks[request.capability];
937
+ check.responded = true;
938
+ if (!message.error) return;
939
+
940
+ check.error = message.error;
941
+ const unsupported = isUnsupportedMethodError(message.error);
942
+ const code = unsupported ? 'capability-list-unsupported' : 'capability-list-error';
943
+ const messageText = unsupported
944
+ ? `${request.capability} capability is advertised but ${request.method} returned Method not found`
945
+ : `${request.capability} capability is advertised but ${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`;
946
+ addIssue('error', code, messageText, {
947
+ capability: request.capability,
948
+ method: request.method,
949
+ errorCode: message.error.code ?? null
950
+ });
951
+ }
952
+
953
+ function recordCapabilityListShape(request, responseResult) {
954
+ if (!isObjectRecord(responseResult)) return;
955
+ const check = result.capabilityChecks[request.capability];
956
+ const items = responseResult[request.capability];
957
+ if (check && Array.isArray(items)) {
958
+ check.itemCount = items.length;
959
+ }
960
+ }
961
+
962
+ function addCapabilityMissingResponseIssue(request) {
963
+ addIssue('error', 'capability-list-missing-response', `${request.method} did not receive a response before server exit`, {
964
+ capability: request.capability,
965
+ method: request.method,
966
+ detailCode: 'capability-missing-response'
967
+ });
968
+ }
969
+
296
970
  const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
297
971
  if (pythonBufferingIssue) {
298
972
  addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
@@ -303,15 +977,34 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
303
977
  env,
304
978
  stdio: ['pipe', 'pipe', 'pipe']
305
979
  });
980
+ result.process.pid = child.pid ?? null;
981
+
982
+ armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`, 'initialize');
306
983
 
307
- armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`);
984
+ child.on('spawn', () => {
985
+ result.process.started = true;
986
+ result.process.pid = child.pid ?? null;
987
+ result.process.outcome = 'running';
988
+ });
308
989
 
309
990
  child.on('error', (error) => {
310
991
  clearTimeout(timer);
311
- addIssue('error', 'spawn-failed', error.message);
992
+ result.process.phase = 'startup';
993
+ result.process.outcome = 'spawn-failed';
994
+ result.process.spawnError = {
995
+ code: error.code || '',
996
+ message: error.message
997
+ };
998
+ addIssue('error', 'spawn-failed', error.message, {
999
+ detailCode: 'spawn-failed-before-startup',
1000
+ phase: 'startup',
1001
+ spawnErrorCode: error.code || ''
1002
+ });
312
1003
  finish();
313
1004
  });
314
1005
 
1006
+ child.stdin.on('error', () => {});
1007
+
315
1008
  child.stdout.on('data', (chunk) => {
316
1009
  stdoutBuffer += chunk.toString('utf8');
317
1010
  const lines = stdoutBuffer.split(/\r?\n/);
@@ -329,17 +1022,36 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
329
1022
  child.on('exit', (code, signal) => {
330
1023
  if (result.durationMs) return;
331
1024
  clearTimeout(timer);
1025
+ const exitPhase = initialized
1026
+ ? currentRequest
1027
+ ? 'operation'
1028
+ : 'post-initialize'
1029
+ : 'initialize';
1030
+ result.process.phase = exitPhase;
1031
+ result.process.outcome = 'exited';
1032
+ result.process.exitCode = code;
1033
+ result.process.signal = signal;
332
1034
  if (stdoutBuffer.trim()) {
333
1035
  addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
334
1036
  }
335
- if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
336
- addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`);
1037
+ if (!endedByGuard && initialized && currentRequest?.kind === 'operation') {
1038
+ const operationResult = result.operations[currentRequest.operationIndex];
1039
+ if (operationResult && !operationResult.responded) {
1040
+ addIssue('error', 'operation-missing-response', `${operationResult.method} did not receive a response before server exit`, {
1041
+ ...exitIssueDetails('during-operation', code, signal),
1042
+ operationIndex: currentRequest.operationIndex,
1043
+ method: operationResult.method
1044
+ });
1045
+ }
1046
+ }
1047
+ if (!endedByGuard && initialized && currentRequest?.capability) {
1048
+ addCapabilityMissingResponseIssue(currentRequest);
337
1049
  }
338
- if (!endedByGuard && initialized && code && code !== 0) {
339
- addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})`);
1050
+ if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
1051
+ addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
340
1052
  }
341
1053
  if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
342
- addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`);
1054
+ addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('before-initialize', code, signal));
343
1055
  }
344
1056
  finish();
345
1057
  });
@@ -386,15 +1098,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
386
1098
 
387
1099
  frames.push(message);
388
1100
 
389
- if (isResponseIdTypeMismatch(message, 1)) {
1101
+ if (!initialized && isResponseIdTypeMismatch(message, 1)) {
390
1102
  clearTimeout(timer);
391
1103
  addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
392
1104
  finish();
393
1105
  return;
394
1106
  }
395
1107
 
396
- if (message.id === 1) {
1108
+ if (!initialized && isResponseIdMismatch(message, 1)) {
1109
+ clearTimeout(timer);
1110
+ addIssue('error', 'response-id-mismatch', `initialize response id ${JSON.stringify(message.id)} does not match request id 1`);
1111
+ finish();
1112
+ return;
1113
+ }
1114
+
1115
+ if (!initialized && message.id === 1) {
397
1116
  clearTimeout(timer);
1117
+ initializeResponseAt = Date.now();
398
1118
  if (!isJsonRpcResponse(message)) {
399
1119
  addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
400
1120
  finish();
@@ -407,41 +1127,36 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
407
1127
  return;
408
1128
  }
409
1129
 
1130
+ const initializeIssues = validateInitializeResult(message.result);
1131
+ for (const issue of initializeIssues) {
1132
+ addIssue(issue.severity, issue.code, issue.message);
1133
+ }
1134
+ if (initializeIssues.some((issue) => issue.severity === 'error')) {
1135
+ finish();
1136
+ return;
1137
+ }
1138
+
410
1139
  initialized = true;
1140
+ configureCapabilityChecks(message.result.capabilities);
1141
+ result.process.phase = 'post-initialize';
411
1142
  result.negotiatedProtocol = message.result?.protocolVersion || '';
412
1143
  send({ jsonrpc: '2.0', method: 'notifications/initialized' });
413
- if (operation) {
414
- const request = {
415
- jsonrpc: '2.0',
416
- id: 2,
417
- method: operation.method
418
- };
419
- if (operation.params !== undefined) {
420
- request.params = operation.params;
421
- }
422
- send(request);
423
- armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`);
424
- } else {
425
- finishSoon();
426
- }
427
- } else if (operation && isResponseIdTypeMismatch(message, 2)) {
1144
+ enqueuePostInitializeRequests();
1145
+ startNextRequest();
1146
+ } else if (initialized && currentRequest && isResponseIdTypeMismatch(message, currentRequest.id)) {
428
1147
  clearTimeout(timer);
429
- addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
1148
+ addIssue('error', 'response-id-type-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not exactly match request id ${currentRequest.id}`);
430
1149
  finish();
431
- } else if (operation && message.id === 2) {
1150
+ } else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
432
1151
  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
-
439
- result.operation.responded = true;
440
- if (message.error) {
441
- result.operation.error = message.error;
442
- addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
443
- }
444
- finishSoon();
1152
+ addIssue('error', 'response-id-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not match request id ${currentRequest.id}`);
1153
+ finish();
1154
+ } else if (initialized && currentRequest && message.id === currentRequest.id) {
1155
+ handleCurrentRequestResponse(message);
1156
+ } else if (initialized && !currentRequest && isJsonRpcResponse(message)) {
1157
+ clearTimeout(timer);
1158
+ addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
1159
+ finish();
445
1160
  }
446
1161
  }
447
1162
  });
@@ -467,10 +1182,92 @@ export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
467
1182
  return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
468
1183
  }
469
1184
 
1185
+ function defaultProcessInfo(timeoutMs) {
1186
+ return {
1187
+ started: false,
1188
+ pid: null,
1189
+ outcome: 'starting',
1190
+ phase: 'initialize',
1191
+ exitCode: null,
1192
+ signal: null,
1193
+ timedOut: false,
1194
+ timeoutCode: '',
1195
+ timeoutMs,
1196
+ killedByGuard: false,
1197
+ killSignal: '',
1198
+ killReason: '',
1199
+ spawnError: null
1200
+ };
1201
+ }
1202
+
1203
+ function timeoutIssueDetails(code, timeoutMs, phase) {
1204
+ return {
1205
+ detailCode: code === 'operation-timeout'
1206
+ ? 'request-timeout'
1207
+ : code === 'capability-list-timeout'
1208
+ ? 'capability-request-timeout'
1209
+ : 'startup-timeout',
1210
+ phase,
1211
+ timeoutMs
1212
+ };
1213
+ }
1214
+
1215
+ function defaultCapabilityChecks() {
1216
+ return Object.fromEntries(CAPABILITY_DEFINITIONS.map((definition) => [
1217
+ definition.name,
1218
+ {
1219
+ advertised: false,
1220
+ method: definition.method,
1221
+ responded: false,
1222
+ itemCount: null,
1223
+ error: null
1224
+ }
1225
+ ]));
1226
+ }
1227
+
1228
+ function capabilityNameForMethod(method) {
1229
+ return CAPABILITY_DEFINITIONS.find((definition) => definition.method === method)?.name ?? '';
1230
+ }
1231
+
1232
+ function capabilityKeys(capabilities) {
1233
+ return isObjectRecord(capabilities) ? Object.keys(capabilities).sort() : [];
1234
+ }
1235
+
1236
+ function isUnsupportedMethodError(error) {
1237
+ return error?.code === -32601 || /method not found/i.test(error?.message || '');
1238
+ }
1239
+
1240
+ function exitIssueDetails(position, code, signal) {
1241
+ return {
1242
+ detailCode: exitDetailCode(position, code, signal),
1243
+ phase: position === 'during-operation'
1244
+ ? 'operation'
1245
+ : position === 'before-initialize'
1246
+ ? 'initialize'
1247
+ : 'post-initialize',
1248
+ exitCode: code,
1249
+ signal
1250
+ };
1251
+ }
1252
+
1253
+ function exitDetailCode(position, code, signal) {
1254
+ if (signal) return `signal-exit-${position}`;
1255
+ if (code === 0) return `clean-exit-${position}`;
1256
+ return `nonzero-exit-${position}`;
1257
+ }
1258
+
1259
+ function isAbnormalExit(code, signal) {
1260
+ return signal !== null || (code !== null && code !== 0);
1261
+ }
1262
+
470
1263
  function isResponseIdTypeMismatch(message, expectedId) {
471
1264
  return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
472
1265
  }
473
1266
 
1267
+ function isResponseIdMismatch(message, expectedId) {
1268
+ return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) !== String(expectedId);
1269
+ }
1270
+
474
1271
  function isJsonRpcResponse(message) {
475
1272
  return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
476
1273
  }
@@ -493,10 +1290,38 @@ export function validateJsonRpc(message) {
493
1290
  const hasResult = Object.hasOwn(message, 'result');
494
1291
  const hasError = Object.hasOwn(message, 'error');
495
1292
 
1293
+ if (hasId && !isJsonRpcId(message.id)) {
1294
+ return 'JSON-RPC id must be a string, integer number, or null';
1295
+ }
1296
+
1297
+ if (hasMethod && hasId && message.id === null) {
1298
+ return 'JSON-RPC request id must not be null';
1299
+ }
1300
+
496
1301
  if (hasMethod && (hasResult || hasError)) {
497
1302
  return 'request/notification frame must not include result or error';
498
1303
  }
499
1304
 
1305
+ if (!hasMethod && hasResult && hasError) {
1306
+ return 'response frame must not include both result and error';
1307
+ }
1308
+
1309
+ if (!hasMethod && !hasId && (hasResult || hasError)) {
1310
+ return 'response frame must include id';
1311
+ }
1312
+
1313
+ if (!hasMethod && hasError) {
1314
+ if (!message.error || typeof message.error !== 'object' || Array.isArray(message.error)) {
1315
+ return 'JSON-RPC error must be an object';
1316
+ }
1317
+ if (!Number.isInteger(message.error.code)) {
1318
+ return 'JSON-RPC error code must be an integer';
1319
+ }
1320
+ if (typeof message.error.message !== 'string') {
1321
+ return 'JSON-RPC error message must be a string';
1322
+ }
1323
+ }
1324
+
500
1325
  if (hasId && !hasMethod && !hasResult && !hasError) {
501
1326
  return 'response frame must include result or error';
502
1327
  }
@@ -508,21 +1333,731 @@ export function validateJsonRpc(message) {
508
1333
  return '';
509
1334
  }
510
1335
 
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;
1336
+ function isJsonRpcId(id) {
1337
+ return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isInteger(id));
518
1338
  }
519
1339
 
520
- function buildChecks(result) {
521
- const issues = result.issues ?? [];
522
- const repeated = Array.isArray(result.runs);
1340
+ function validateInitializeResult(result) {
1341
+ const issues = [];
523
1342
 
524
- return {
525
- initialize: repeated
1343
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
1344
+ return [{
1345
+ severity: 'error',
1346
+ code: 'initialize-invalid-result',
1347
+ message: 'initialize result must be an object with protocolVersion and capabilities'
1348
+ }];
1349
+ }
1350
+
1351
+ if (!Object.hasOwn(result, 'protocolVersion')) {
1352
+ issues.push({
1353
+ severity: 'error',
1354
+ code: 'initialize-missing-protocol-version',
1355
+ message: 'initialize result is missing protocolVersion'
1356
+ });
1357
+ } else if (typeof result.protocolVersion !== 'string' || !result.protocolVersion) {
1358
+ issues.push({
1359
+ severity: 'error',
1360
+ code: 'initialize-invalid-protocol-version',
1361
+ message: 'initialize result protocolVersion must be a non-empty string'
1362
+ });
1363
+ }
1364
+
1365
+ if (!Object.hasOwn(result, 'capabilities')) {
1366
+ issues.push({
1367
+ severity: 'error',
1368
+ code: 'initialize-missing-capabilities',
1369
+ message: 'initialize result is missing capabilities'
1370
+ });
1371
+ } else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
1372
+ issues.push({
1373
+ severity: 'error',
1374
+ code: 'initialize-invalid-capabilities',
1375
+ message: 'initialize result capabilities must be an object'
1376
+ });
1377
+ }
1378
+
1379
+ if (!Object.hasOwn(result, 'serverInfo')) {
1380
+ issues.push({
1381
+ severity: 'warning',
1382
+ code: 'initialize-missing-server-info',
1383
+ message: 'initialize result is missing serverInfo'
1384
+ });
1385
+ } else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
1386
+ issues.push({
1387
+ severity: 'warning',
1388
+ code: 'initialize-invalid-server-info',
1389
+ message: 'initialize result serverInfo should be an object'
1390
+ });
1391
+ }
1392
+
1393
+ return issues;
1394
+ }
1395
+
1396
+ function validateToolsListResult(result) {
1397
+ const summary = {
1398
+ ...defaultToolSchemaValidation(),
1399
+ checked: true
1400
+ };
1401
+ const issues = [];
1402
+
1403
+ if (!isObjectRecord(result) || !Array.isArray(result.tools)) {
1404
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', 'tools/list result must be an object with a tools array');
1405
+ summary.errorCount = 1;
1406
+ return { summary, issues };
1407
+ }
1408
+
1409
+ summary.toolCount = result.tools.length;
1410
+ const seenNames = new Map();
1411
+
1412
+ for (let index = 0; index < result.tools.length; index += 1) {
1413
+ const tool = result.tools[index];
1414
+ let toolValid = true;
1415
+
1416
+ if (!isObjectRecord(tool)) {
1417
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', `tools[${index}] must be an object`, { toolIndex: index });
1418
+ continue;
1419
+ }
1420
+
1421
+ const name = validateToolName(tool, index, issues);
1422
+ toolValid = Boolean(name);
1423
+ if (name) {
1424
+ summary.toolNames.push(name);
1425
+ if (seenNames.has(name)) {
1426
+ const firstToolIndex = seenNames.get(name);
1427
+ toolValid = false;
1428
+ summary.duplicateNames.push(name);
1429
+ addToolSchemaIssue(
1430
+ issues,
1431
+ 'error',
1432
+ 'tool-name-duplicate',
1433
+ `tools/list returned duplicate tool name ${JSON.stringify(name)}`,
1434
+ { toolIndex: index, firstToolIndex, toolName: name }
1435
+ );
1436
+ } else {
1437
+ seenNames.set(name, index);
1438
+ }
1439
+ }
1440
+
1441
+ if (typeof tool.description !== 'string' || !tool.description.trim()) {
1442
+ addToolSchemaIssue(
1443
+ issues,
1444
+ 'warning',
1445
+ 'tool-description-missing',
1446
+ name
1447
+ ? `tool ${JSON.stringify(name)} is missing a non-empty description`
1448
+ : `tools[${index}] is missing a non-empty description`,
1449
+ { toolIndex: index, toolName: name || '' }
1450
+ );
1451
+ }
1452
+
1453
+ if (!Object.hasOwn(tool, 'inputSchema')) {
1454
+ addToolSchemaIssue(
1455
+ issues,
1456
+ 'error',
1457
+ 'tool-input-schema-invalid',
1458
+ name
1459
+ ? `tool ${JSON.stringify(name)} is missing inputSchema`
1460
+ : `tools[${index}] is missing inputSchema`,
1461
+ { toolIndex: index, toolName: name || '', schemaPath: 'inputSchema' }
1462
+ );
1463
+ toolValid = false;
1464
+ } else {
1465
+ toolValid = validateInputSchema(tool.inputSchema, {
1466
+ toolIndex: index,
1467
+ toolName: name || '',
1468
+ schemaPath: 'inputSchema'
1469
+ }, issues) && toolValid;
1470
+ }
1471
+
1472
+ if (toolValid) {
1473
+ summary.validToolCount += 1;
1474
+ }
1475
+ }
1476
+
1477
+ summary.duplicateNames = [...new Set(summary.duplicateNames)].sort();
1478
+ summary.toolNames = [...new Set(summary.toolNames)].sort();
1479
+ summary.errorCount = issues.filter((issue) => issue.severity === 'error').length;
1480
+ summary.warningCount = issues.filter((issue) => issue.severity === 'warning').length;
1481
+ return { summary, issues };
1482
+ }
1483
+
1484
+ function validateToolName(tool, index, issues) {
1485
+ if (typeof tool.name !== 'string' || !tool.name.trim() || tool.name !== tool.name.trim()) {
1486
+ addToolSchemaIssue(issues, 'error', 'tool-name-invalid', `tools[${index}] name must be a stable non-empty string`, {
1487
+ toolIndex: index,
1488
+ toolName: typeof tool.name === 'string' ? tool.name : ''
1489
+ });
1490
+ return '';
1491
+ }
1492
+
1493
+ return tool.name;
1494
+ }
1495
+
1496
+ function validateInputSchema(schema, context, issues) {
1497
+ return validateSchemaValue(schema, context, issues);
1498
+ }
1499
+
1500
+ function validateSchemaValue(schema, context, issues) {
1501
+ if (typeof schema === 'boolean') {
1502
+ return true;
1503
+ }
1504
+
1505
+ return validateSchemaObject(schema, context, issues);
1506
+ }
1507
+
1508
+ function validateSchemaObject(schema, context, issues) {
1509
+ if (!isObjectRecord(schema)) {
1510
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a JSON Schema object or boolean schema`, context);
1511
+ return false;
1512
+ }
1513
+
1514
+ let valid = true;
1515
+
1516
+ if (Object.hasOwn(schema, 'type') && !isValidJsonSchemaType(schema.type)) {
1517
+ valid = false;
1518
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.type must be a string or array of strings`, context);
1519
+ }
1520
+
1521
+ if (Object.hasOwn(schema, 'properties')) {
1522
+ if (!isObjectRecord(schema.properties)) {
1523
+ valid = false;
1524
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.properties must be an object`, context);
1525
+ } else {
1526
+ for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
1527
+ valid = validateSchemaValue(propertySchema, {
1528
+ ...context,
1529
+ schemaPath: `${context.schemaPath}.properties.${propertyName}`
1530
+ }, issues) && valid;
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ if (Object.hasOwn(schema, 'required')) {
1536
+ valid = validateRequired(schema, context, issues) && valid;
1537
+ }
1538
+
1539
+ if (Object.hasOwn(schema, 'items')) {
1540
+ valid = validateSchemaValueOrArray(schema.items, {
1541
+ ...context,
1542
+ schemaPath: `${context.schemaPath}.items`
1543
+ }, issues) && valid;
1544
+ }
1545
+
1546
+ if (Object.hasOwn(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') {
1547
+ valid = validateSchemaObject(schema.additionalProperties, {
1548
+ ...context,
1549
+ schemaPath: `${context.schemaPath}.additionalProperties`
1550
+ }, issues) && valid;
1551
+ }
1552
+
1553
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
1554
+ if (Object.hasOwn(schema, keyword)) {
1555
+ valid = validateSchemaArray(schema[keyword], {
1556
+ ...context,
1557
+ schemaPath: `${context.schemaPath}.${keyword}`
1558
+ }, issues) && valid;
1559
+ }
1560
+ }
1561
+
1562
+ if (Object.hasOwn(schema, 'enum') && !Array.isArray(schema.enum)) {
1563
+ valid = false;
1564
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.enum must be an array`, context);
1565
+ }
1566
+
1567
+ return valid;
1568
+ }
1569
+
1570
+ function validateSchemaValueOrArray(value, context, issues) {
1571
+ if (Array.isArray(value)) {
1572
+ return validateSchemaArray(value, context, issues);
1573
+ }
1574
+
1575
+ return validateSchemaValue(value, context, issues);
1576
+ }
1577
+
1578
+ function validateSchemaArray(value, context, issues) {
1579
+ if (!Array.isArray(value) || !value.length) {
1580
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a non-empty array of schemas`, context);
1581
+ return false;
1582
+ }
1583
+
1584
+ let valid = true;
1585
+ for (let index = 0; index < value.length; index += 1) {
1586
+ valid = validateSchemaValue(value[index], {
1587
+ ...context,
1588
+ schemaPath: `${context.schemaPath}[${index}]`
1589
+ }, issues) && valid;
1590
+ }
1591
+ return valid;
1592
+ }
1593
+
1594
+ function validateRequired(schema, context, issues) {
1595
+ if (!Array.isArray(schema.required) || schema.required.some((entry) => typeof entry !== 'string' || !entry)) {
1596
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must be an array of non-empty strings`, context);
1597
+ return false;
1598
+ }
1599
+
1600
+ let valid = true;
1601
+ const seen = new Set();
1602
+ for (const propertyName of schema.required) {
1603
+ if (seen.has(propertyName)) {
1604
+ valid = false;
1605
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must not contain duplicate entries`, {
1606
+ ...context,
1607
+ propertyName
1608
+ });
1609
+ }
1610
+ seen.add(propertyName);
1611
+
1612
+ if (!isObjectRecord(schema.properties) || !Object.hasOwn(schema.properties, propertyName)) {
1613
+ valid = false;
1614
+ addToolSchemaIssue(
1615
+ issues,
1616
+ 'error',
1617
+ 'tool-input-schema-required-missing',
1618
+ `${context.schemaPath}.required references missing property ${JSON.stringify(propertyName)}`,
1619
+ { ...context, propertyName }
1620
+ );
1621
+ }
1622
+ }
1623
+
1624
+ return valid;
1625
+ }
1626
+
1627
+ function isValidJsonSchemaType(value) {
1628
+ if (typeof value === 'string') {
1629
+ return JSON_SCHEMA_TYPES.has(value);
1630
+ }
1631
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && JSON_SCHEMA_TYPES.has(entry));
1632
+ }
1633
+
1634
+ function addToolSchemaIssue(issues, severity, code, message, details = {}) {
1635
+ issues.push({
1636
+ severity,
1637
+ code,
1638
+ message,
1639
+ details
1640
+ });
1641
+ }
1642
+
1643
+ function isObjectRecord(value) {
1644
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1645
+ }
1646
+
1647
+ export function classifyIssueCode(code) {
1648
+ return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
1649
+ }
1650
+
1651
+ export function createFingerprint(commandWithArgs, options = {}) {
1652
+ const cwd = path.resolve(options.cwd ?? process.cwd());
1653
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
1654
+
1655
+ return {
1656
+ guard: {
1657
+ name: 'mcp-stdio-guard',
1658
+ version: VERSION
1659
+ },
1660
+ command: {
1661
+ executable: commandWithArgs[0] || '',
1662
+ args: redactArgv(commandWithArgs.slice(1)),
1663
+ argv: redactArgv(commandWithArgs)
1664
+ },
1665
+ cwd: {
1666
+ requested: String(options.cwd ?? process.cwd()),
1667
+ resolved: cwd,
1668
+ exists: fs.existsSync(cwd)
1669
+ },
1670
+ protocol: options.protocol ?? DEFAULT_PROTOCOL,
1671
+ config: options.config ?? defaultConfigMetadata(),
1672
+ profile: options.profile ?? DEFAULT_PROFILE,
1673
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
1674
+ repeat: options.repeat ?? 1,
1675
+ capabilityProbes: options.probeCapabilities ?? true,
1676
+ operation: operations.length === 1
1677
+ ? {
1678
+ method: operations[0].method,
1679
+ hasParams: operations[0].params !== undefined,
1680
+ source: operations[0].source,
1681
+ safeToolCallName: operations[0].safeToolCallName
1682
+ }
1683
+ : null,
1684
+ operations: operations.map((operation) => ({
1685
+ method: operation.method,
1686
+ hasParams: operation.params !== undefined,
1687
+ source: operation.source,
1688
+ safeToolCallName: operation.safeToolCallName
1689
+ })),
1690
+ system: {
1691
+ platform: process.platform,
1692
+ arch: process.arch,
1693
+ osRelease: os.release()
1694
+ },
1695
+ runtimes: detectRuntimeVersions(commandWithArgs),
1696
+ package: detectPackageMetadata(commandWithArgs, cwd),
1697
+ env: redactEnvMetadata(options.env ?? {}),
1698
+ staticScan: defaultStaticScan(),
1699
+ timings: {
1700
+ startupMs: null,
1701
+ totalMs: null
1702
+ }
1703
+ };
1704
+ }
1705
+
1706
+ function redactEnvMetadata(env) {
1707
+ const names = Object.keys(env).sort();
1708
+ return {
1709
+ inherited: true,
1710
+ names,
1711
+ values: Object.fromEntries(names.map((name) => [name, REDACTED]))
1712
+ };
1713
+ }
1714
+
1715
+ function redactArgv(argv) {
1716
+ const redacted = [];
1717
+ let redactNext = false;
1718
+
1719
+ for (const arg of argv) {
1720
+ if (redactNext) {
1721
+ redacted.push(REDACTED);
1722
+ redactNext = false;
1723
+ continue;
1724
+ }
1725
+
1726
+ const secretAssignment = redactSecretAssignment(arg);
1727
+ if (secretAssignment) {
1728
+ redacted.push(secretAssignment);
1729
+ continue;
1730
+ }
1731
+
1732
+ redacted.push(arg);
1733
+ if (isSecretFlag(arg)) {
1734
+ redactNext = true;
1735
+ }
1736
+ }
1737
+
1738
+ return redacted;
1739
+ }
1740
+
1741
+ function redactSecretAssignment(arg) {
1742
+ const match = /^([^=\s]+)=(.*)$/.exec(arg);
1743
+ if (!match) return '';
1744
+
1745
+ const [, name] = match;
1746
+ return isSecretName(name) ? `${name}=${REDACTED}` : '';
1747
+ }
1748
+
1749
+ function isSecretFlag(arg) {
1750
+ if (!arg.startsWith('-')) return false;
1751
+ const name = arg.replace(/^-+/, '').split(/[=\s]/)[0];
1752
+ return isSecretName(name);
1753
+ }
1754
+
1755
+ function isSecretName(name) {
1756
+ return /(^|[-_])(api[-_]?key|auth|bearer|cookie|credential|password|passwd|private[-_]?key|pwd|secret|session|token)([-_]|$)/i.test(name);
1757
+ }
1758
+
1759
+ function detectRuntimeVersions(commandWithArgs) {
1760
+ const command = commandWithArgs[0] || '';
1761
+ const base = path.basename(command).toLowerCase();
1762
+ const runtimes = {
1763
+ node: {
1764
+ version: process.version,
1765
+ role: isNodeCommand(command) ? 'guard-and-target' : 'guard'
1766
+ }
1767
+ };
1768
+
1769
+ if (isPythonCommand(command)) {
1770
+ runtimes.python = executableVersion(command, ['--version']);
1771
+ }
1772
+
1773
+ if (isNpmCommand(base) || isNpxCommand(base)) {
1774
+ runtimes.npm = executableVersion('npm', ['--version']);
1775
+ }
1776
+
1777
+ if (base === 'uv' || base === 'uvx') {
1778
+ runtimes.uv = executableVersion(base === 'uvx' ? 'uv' : command, ['--version']);
1779
+ }
1780
+
1781
+ if (base === 'docker') {
1782
+ runtimes.docker = executableVersion(command, ['--version']);
1783
+ }
1784
+
1785
+ return runtimes;
1786
+ }
1787
+
1788
+ function executableVersion(command, args) {
1789
+ const cacheKey = JSON.stringify([command, args]);
1790
+ if (VERSION_PROBE_CACHE.has(cacheKey)) {
1791
+ return { ...VERSION_PROBE_CACHE.get(cacheKey) };
1792
+ }
1793
+
1794
+ const result = spawnSync(command, args, {
1795
+ encoding: 'utf8',
1796
+ timeout: 1000,
1797
+ stdio: ['ignore', 'pipe', 'pipe']
1798
+ });
1799
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
1800
+ const version = {
1801
+ command,
1802
+ version: output.split(/\r?\n/)[0] || '',
1803
+ available: !result.error && result.status === 0 && result.signal === null,
1804
+ status: result.status,
1805
+ signal: result.signal
1806
+ };
1807
+ VERSION_PROBE_CACHE.set(cacheKey, version);
1808
+ return { ...version };
1809
+ }
1810
+
1811
+ function detectPackageMetadata(commandWithArgs, cwd) {
1812
+ const command = commandWithArgs[0] || '';
1813
+ const args = commandWithArgs.slice(1);
1814
+ const base = path.basename(command).toLowerCase();
1815
+
1816
+ if (isNpxCommand(base)) {
1817
+ return packageFromSpec('npm', firstPackageSpec(args));
1818
+ }
1819
+
1820
+ if (isNpmCommand(base)) {
1821
+ const subcommand = args[0] || '';
1822
+ if (subcommand === 'exec' || subcommand === 'x') {
1823
+ return packageFromSpec('npm', firstPackageSpec(args.slice(1)));
1824
+ }
1825
+ }
1826
+
1827
+ if (base === 'uvx') {
1828
+ return packageFromSpec('uv', firstPackageSpec(args));
1829
+ }
1830
+
1831
+ if (base === 'docker') {
1832
+ const image = dockerImageSpec(args);
1833
+ return image ? { manager: 'docker', name: image, versionSpec: '' } : null;
1834
+ }
1835
+
1836
+ if (isNodeCommand(command)) {
1837
+ const entrypoint = nodeEntrypointArg(args);
1838
+ return entrypoint ? localPackageMetadata(path.resolve(cwd, entrypoint)) : null;
1839
+ }
1840
+
1841
+ return null;
1842
+ }
1843
+
1844
+ function firstPackageSpec(args) {
1845
+ for (let index = 0; index < args.length; index += 1) {
1846
+ const arg = args[index];
1847
+ if (arg === '--') continue;
1848
+
1849
+ if (arg.startsWith('--package=')) {
1850
+ return arg.slice('--package='.length);
1851
+ }
1852
+
1853
+ if (arg === '--package' || arg === '-p') {
1854
+ return args[index + 1] || '';
1855
+ }
1856
+
1857
+ if (arg.startsWith('-')) {
1858
+ continue;
1859
+ }
1860
+
1861
+ return arg;
1862
+ }
1863
+
1864
+ return '';
1865
+ }
1866
+
1867
+ function nodeEntrypointArg(args) {
1868
+ let afterSeparator = false;
1869
+
1870
+ for (let index = 0; index < args.length; index += 1) {
1871
+ const arg = args[index];
1872
+
1873
+ if (!afterSeparator && arg === '--') {
1874
+ afterSeparator = true;
1875
+ continue;
1876
+ }
1877
+
1878
+ if (!afterSeparator && arg.startsWith('-')) {
1879
+ const optionName = arg.split('=')[0];
1880
+ if (NODE_EVAL_OPTIONS.has(optionName)) {
1881
+ return '';
1882
+ }
1883
+ if (NODE_OPTIONS_WITH_VALUES.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
1884
+ index += 1;
1885
+ }
1886
+ continue;
1887
+ }
1888
+
1889
+ return arg;
1890
+ }
1891
+
1892
+ return '';
1893
+ }
1894
+
1895
+ function packageFromSpec(manager, spec) {
1896
+ if (!spec) return null;
1897
+ const parsed = parsePackageSpec(spec);
1898
+ return {
1899
+ manager,
1900
+ name: parsed.name,
1901
+ versionSpec: parsed.versionSpec
1902
+ };
1903
+ }
1904
+
1905
+ function parsePackageSpec(spec) {
1906
+ if (spec.startsWith('@')) {
1907
+ const versionAt = spec.indexOf('@', 1);
1908
+ if (versionAt > -1) {
1909
+ return {
1910
+ name: spec.slice(0, versionAt),
1911
+ versionSpec: spec.slice(versionAt + 1)
1912
+ };
1913
+ }
1914
+ return { name: spec, versionSpec: '' };
1915
+ }
1916
+
1917
+ const versionAt = spec.lastIndexOf('@');
1918
+ if (versionAt > 0) {
1919
+ return {
1920
+ name: spec.slice(0, versionAt),
1921
+ versionSpec: spec.slice(versionAt + 1)
1922
+ };
1923
+ }
1924
+
1925
+ return { name: spec, versionSpec: '' };
1926
+ }
1927
+
1928
+ function dockerImageSpec(args) {
1929
+ const runIndex = args.indexOf('run');
1930
+ if (runIndex === -1) return '';
1931
+ const optionsWithValues = new Set([
1932
+ '-e',
1933
+ '--env',
1934
+ '-h',
1935
+ '--hostname',
1936
+ '-p',
1937
+ '--publish',
1938
+ '-u',
1939
+ '--user',
1940
+ '-v',
1941
+ '--volume',
1942
+ '-w',
1943
+ '--workdir',
1944
+ '--entrypoint',
1945
+ '--name',
1946
+ '--network',
1947
+ '--platform'
1948
+ ]);
1949
+
1950
+ for (let index = runIndex + 1; index < args.length; index += 1) {
1951
+ const arg = args[index];
1952
+ if (arg === '--') continue;
1953
+ if (arg.startsWith('-')) {
1954
+ const optionName = arg.split('=')[0];
1955
+ if (optionsWithValues.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
1956
+ index += 1;
1957
+ }
1958
+ continue;
1959
+ }
1960
+ return arg;
1961
+ }
1962
+
1963
+ return '';
1964
+ }
1965
+
1966
+ function localPackageMetadata(entry) {
1967
+ const packageJson = findNearestPackageJson(fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? entry : path.dirname(entry));
1968
+ if (!packageJson) return null;
1969
+
1970
+ try {
1971
+ const parsed = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
1972
+ return {
1973
+ manager: 'local',
1974
+ name: typeof parsed.name === 'string' ? parsed.name : '',
1975
+ versionSpec: typeof parsed.version === 'string' ? parsed.version : ''
1976
+ };
1977
+ } catch {
1978
+ return null;
1979
+ }
1980
+ }
1981
+
1982
+ function findNearestPackageJson(start) {
1983
+ let dir = path.resolve(start);
1984
+ while (dir !== path.dirname(dir)) {
1985
+ const candidate = path.join(dir, 'package.json');
1986
+ if (fs.existsSync(candidate)) return candidate;
1987
+ dir = path.dirname(dir);
1988
+ }
1989
+ return '';
1990
+ }
1991
+
1992
+ function isNodeCommand(command) {
1993
+ const base = path.basename(command).toLowerCase();
1994
+ return base === 'node' || base === 'node.exe' || command === process.execPath;
1995
+ }
1996
+
1997
+ function isPythonCommand(command) {
1998
+ const base = path.basename(command).toLowerCase();
1999
+ return /^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(base);
2000
+ }
2001
+
2002
+ function isNpmCommand(base) {
2003
+ return base === 'npm' || base === 'npm.cmd' || base === 'npm-cli.js';
2004
+ }
2005
+
2006
+ function isNpxCommand(base) {
2007
+ return base === 'npx' || base === 'npx.cmd';
2008
+ }
2009
+
2010
+ function finalizeResult(result) {
2011
+ result.schemaVersion = JSON_SCHEMA_VERSION;
2012
+ result.config ??= defaultConfigMetadata();
2013
+ result.profile ??= DEFAULT_PROFILE;
2014
+ result.capabilityProbes ??= true;
2015
+ result.operations ??= result.operation ? [{
2016
+ index: 0,
2017
+ source: result.operation.source ?? 'request',
2018
+ method: result.operation.method,
2019
+ safeToolCallName: result.operation.safeToolCallName ?? '',
2020
+ responded: Boolean(result.operation.responded),
2021
+ error: result.operation.error ?? null
2022
+ }] : [];
2023
+ result.staticScan ??= defaultStaticScan();
2024
+ result.staticFindings ??= [];
2025
+ result.issues = normalizeIssues(result.issues ?? []);
2026
+ result.ok = !result.issues.some((issue) => issue.severity === 'error');
2027
+ result.checks = buildChecks(result);
2028
+ result.issueClasses = buildIssueClasses(result.issues);
2029
+ finalizeFingerprint(result);
2030
+ return result;
2031
+ }
2032
+
2033
+ function finalizeFingerprint(result) {
2034
+ if (!result.fingerprint) return;
2035
+ result.fingerprint.timings ??= {};
2036
+ result.fingerprint.timings.totalMs = result.durationMs ?? result.fingerprint.timings.totalMs ?? null;
2037
+
2038
+ if (Array.isArray(result.runs)) {
2039
+ result.fingerprint.runs = result.runs.map((run) => ({
2040
+ run: run.run,
2041
+ ok: run.ok,
2042
+ startupMs: run.fingerprint?.timings?.startupMs ?? null,
2043
+ totalMs: run.durationMs ?? run.fingerprint?.timings?.totalMs ?? null
2044
+ }));
2045
+ }
2046
+ }
2047
+
2048
+ function normalizeIssues(issues) {
2049
+ return issues.map((issue) => ({
2050
+ ...issue,
2051
+ class: classifyIssueCode(issue.code)
2052
+ }));
2053
+ }
2054
+
2055
+ function buildChecks(result) {
2056
+ const issues = result.issues ?? [];
2057
+ const repeated = Array.isArray(result.runs);
2058
+
2059
+ return {
2060
+ initialize: repeated
526
2061
  ? aggregateRunCheck(result, 'initialize')
527
2062
  : buildInitializeCheck(result, issues),
528
2063
  stdout: buildIssueCheck(issues, (issue) => STDOUT_ISSUE_CODES.has(issue.code)),
@@ -530,6 +2065,12 @@ function buildChecks(result) {
530
2065
  operation: repeated
531
2066
  ? aggregateRunCheck(result, 'operation')
532
2067
  : buildOperationCheck(result, issues),
2068
+ capabilities: repeated
2069
+ ? aggregateCapabilityChecks(result)
2070
+ : buildCapabilityChecks(result, issues),
2071
+ toolSchema: repeated
2072
+ ? aggregateRunCheck(result, 'toolSchema')
2073
+ : buildToolSchemaCheck(result, issues),
533
2074
  process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
534
2075
  pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
535
2076
  staticScan: buildStaticScanCheck(result, issues),
@@ -537,6 +2078,13 @@ function buildChecks(result) {
537
2078
  };
538
2079
  }
539
2080
 
2081
+ function buildIssueClasses(issues) {
2082
+ return Object.fromEntries(ISSUE_CLASS_NAMES.map((className) => [
2083
+ className,
2084
+ buildIssueCheck(issues, (issue) => issue.class === className)
2085
+ ]));
2086
+ }
2087
+
540
2088
  function buildInitializeCheck(result, issues) {
541
2089
  const matched = issues.filter((issue) => (
542
2090
  INITIALIZE_ISSUE_CODES.has(issue.code)
@@ -548,7 +2096,13 @@ function buildInitializeCheck(result, issues) {
548
2096
  }
549
2097
 
550
2098
  function buildOperationCheck(result, issues) {
551
- if (!result.operation) {
2099
+ const operations = result.operations?.length
2100
+ ? result.operations
2101
+ : result.operation
2102
+ ? [result.operation]
2103
+ : [];
2104
+
2105
+ if (!operations.length) {
552
2106
  return makeCheck('skipped', []);
553
2107
  }
554
2108
 
@@ -558,14 +2112,108 @@ function buildOperationCheck(result, issues) {
558
2112
 
559
2113
  const matched = issues.filter((issue) => (
560
2114
  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))
2115
+ || (operations.some((operation) => !operation.responded) && STDOUT_ISSUE_CODES.has(issue.code))
2116
+ || (operations.some((operation) => !operation.responded) && JSON_RPC_ISSUE_CODES.has(issue.code))
563
2117
  ));
564
2118
  if (matched.length) {
565
2119
  return makeCheck(statusFromIssues(matched), matched);
566
2120
  }
567
2121
 
568
- return makeCheck(result.operation.responded ? 'pass' : 'fail', []);
2122
+ return makeCheck(operations.every((operation) => operation.responded) ? 'pass' : 'fail', []);
2123
+ }
2124
+
2125
+ function buildToolSchemaCheck(result, issues) {
2126
+ if (!result.toolSchema?.checked) {
2127
+ return makeCheck('skipped', []);
2128
+ }
2129
+
2130
+ const matched = issues.filter((issue) => TOOL_SCHEMA_ISSUE_CODES.has(issue.code));
2131
+ if (matched.length) {
2132
+ return makeCheck(statusFromIssues(matched), matched);
2133
+ }
2134
+
2135
+ return makeCheck('pass', []);
2136
+ }
2137
+
2138
+ function buildCapabilityChecks(result, issues) {
2139
+ const checks = {};
2140
+ for (const definition of CAPABILITY_DEFINITIONS) {
2141
+ const state = result.capabilityChecks?.[definition.name] ?? defaultCapabilityChecks()[definition.name];
2142
+ const matched = issues.filter((issue) => (
2143
+ CAPABILITY_ISSUE_CODES.has(issue.code)
2144
+ && issue.capability === definition.name
2145
+ ));
2146
+ if (!result.initialized || !state.advertised || result.capabilityProbes === false) {
2147
+ checks[definition.name] = makeCapabilityCheck('skipped', matched, state);
2148
+ } else if (matched.length) {
2149
+ checks[definition.name] = makeCapabilityCheck(statusFromIssues(matched), matched, state);
2150
+ } else {
2151
+ checks[definition.name] = makeCapabilityCheck(state.responded ? 'pass' : 'fail', matched, state);
2152
+ }
2153
+ }
2154
+
2155
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2156
+ const status = active.length
2157
+ ? active.some((check) => check.status === 'fail')
2158
+ ? 'fail'
2159
+ : active.some((check) => check.status === 'warning')
2160
+ ? 'warning'
2161
+ : 'pass'
2162
+ : 'skipped';
2163
+ return {
2164
+ status,
2165
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2166
+ ...checks
2167
+ };
2168
+ }
2169
+
2170
+ function aggregateCapabilityChecks(result) {
2171
+ const checks = {};
2172
+ for (const definition of CAPABILITY_DEFINITIONS) {
2173
+ const runChecks = result.runs
2174
+ .map((run) => run.checks?.capabilities?.[definition.name])
2175
+ .filter(Boolean);
2176
+ const activeRunChecks = runChecks.filter((check) => check.status !== 'skipped');
2177
+ const state = aggregateCapabilityState(definition, runChecks);
2178
+ if (!activeRunChecks.length) {
2179
+ checks[definition.name] = makeAggregatedCapabilityCheck('skipped', [], state);
2180
+ } else {
2181
+ const status = activeRunChecks.some((check) => check.status === 'fail')
2182
+ ? 'fail'
2183
+ : activeRunChecks.some((check) => check.status === 'warning')
2184
+ ? 'warning'
2185
+ : 'pass';
2186
+ checks[definition.name] = makeAggregatedCapabilityCheck(status, activeRunChecks, state);
2187
+ }
2188
+ }
2189
+
2190
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2191
+ const status = active.length
2192
+ ? active.some((check) => check.status === 'fail')
2193
+ ? 'fail'
2194
+ : active.some((check) => check.status === 'warning')
2195
+ ? 'warning'
2196
+ : 'pass'
2197
+ : 'skipped';
2198
+ return {
2199
+ status,
2200
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2201
+ ...checks
2202
+ };
2203
+ }
2204
+
2205
+ function aggregateCapabilityState(definition, runChecks) {
2206
+ const itemCounts = [...new Set(
2207
+ runChecks
2208
+ .map((check) => check.itemCount)
2209
+ .filter((itemCount) => Number.isInteger(itemCount))
2210
+ )];
2211
+ return {
2212
+ advertised: runChecks.some((check) => check.advertised),
2213
+ method: definition.method,
2214
+ responded: runChecks.some((check) => check.responded),
2215
+ itemCount: itemCounts.length === 1 ? itemCounts[0] : null
2216
+ };
569
2217
  }
570
2218
 
571
2219
  function buildStaticScanCheck(result, issues) {
@@ -591,8 +2239,16 @@ function buildRepeatCheck(result) {
591
2239
  }
592
2240
 
593
2241
  const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
2242
+ const repeatIssues = (result.issues ?? []).filter((issue) => (
2243
+ issue.run || REPEAT_DRIFT_ISSUE_CODES.has(issue.code)
2244
+ ));
2245
+ const status = failedRuns.length
2246
+ ? 'fail'
2247
+ : repeatIssues.some((issue) => issue.severity === 'warning')
2248
+ ? 'warning'
2249
+ : 'pass';
594
2250
  return {
595
- ...makeCheck(failedRuns.length ? 'fail' : 'pass', result.issues ?? []),
2251
+ ...makeCheck(status, repeatIssues),
596
2252
  runs: result.runs.length,
597
2253
  passedRuns: result.runs.length - failedRuns.length,
598
2254
  failedRuns
@@ -634,6 +2290,42 @@ function makeCheck(status, issues) {
634
2290
  };
635
2291
  }
636
2292
 
2293
+ function makeCapabilityCheck(status, issues, state) {
2294
+ return {
2295
+ ...makeCheck(status, issues),
2296
+ advertised: Boolean(state.advertised),
2297
+ method: state.method,
2298
+ responded: Boolean(state.responded),
2299
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2300
+ };
2301
+ }
2302
+
2303
+ function makeAggregatedCapabilityCheck(status, checks, state) {
2304
+ return {
2305
+ status,
2306
+ issueCodes: [...new Set(checks.flatMap((check) => check.issueCodes ?? []))].sort(),
2307
+ advertised: Boolean(state.advertised),
2308
+ method: state.method,
2309
+ responded: Boolean(state.responded),
2310
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2311
+ };
2312
+ }
2313
+
2314
+ function defaultConfigMetadata() {
2315
+ return {
2316
+ enabled: false,
2317
+ path: '',
2318
+ resolvedPath: '',
2319
+ checks: {
2320
+ command: false,
2321
+ cwd: false,
2322
+ envNames: [],
2323
+ requests: [],
2324
+ safeToolCalls: []
2325
+ }
2326
+ };
2327
+ }
2328
+
637
2329
  function defaultStaticScan() {
638
2330
  return {
639
2331
  enabled: false,
@@ -642,6 +2334,253 @@ function defaultStaticScan() {
642
2334
  };
643
2335
  }
644
2336
 
2337
+ function defaultToolSchemaValidation() {
2338
+ return {
2339
+ checked: false,
2340
+ toolCount: 0,
2341
+ toolNames: [],
2342
+ validToolCount: 0,
2343
+ warningCount: 0,
2344
+ errorCount: 0,
2345
+ duplicateNames: []
2346
+ };
2347
+ }
2348
+
2349
+ function aggregateRunToolSchemaValidation(runs) {
2350
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
2351
+ if (!checkedRuns.length) {
2352
+ return defaultToolSchemaValidation();
2353
+ }
2354
+
2355
+ return {
2356
+ checked: true,
2357
+ runs: checkedRuns.length,
2358
+ toolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.toolCount, 0),
2359
+ validToolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.validToolCount, 0),
2360
+ warningCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.warningCount, 0),
2361
+ errorCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.errorCount, 0),
2362
+ toolNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.toolNames ?? []))].sort(),
2363
+ duplicateNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.duplicateNames))].sort()
2364
+ };
2365
+ }
2366
+
2367
+ function defaultRepeatDrift() {
2368
+ return {
2369
+ checked: false,
2370
+ status: 'skipped',
2371
+ issueCodes: [],
2372
+ baselineRun: null,
2373
+ comparedRuns: [],
2374
+ negotiatedProtocol: driftSection(),
2375
+ capabilities: driftSection(),
2376
+ tools: driftSection(),
2377
+ lists: {
2378
+ resources: driftSection(),
2379
+ prompts: driftSection()
2380
+ }
2381
+ };
2382
+ }
2383
+
2384
+ function driftSection() {
2385
+ return {
2386
+ status: 'skipped',
2387
+ issueCodes: [],
2388
+ baseline: null,
2389
+ values: [],
2390
+ changedRuns: []
2391
+ };
2392
+ }
2393
+
2394
+ function buildRepeatDrift(runs) {
2395
+ const drift = defaultRepeatDrift();
2396
+ if (!Array.isArray(runs) || runs.length < 2) {
2397
+ return drift;
2398
+ }
2399
+
2400
+ drift.checked = true;
2401
+ const comparableRuns = runs.filter((run) => run.initialized);
2402
+ drift.comparedRuns = comparableRuns.map((run) => run.run);
2403
+ if (comparableRuns.length < 2) {
2404
+ return drift;
2405
+ }
2406
+
2407
+ drift.baselineRun = comparableRuns[0].run;
2408
+ drift.negotiatedProtocol = compareScalarDrift(
2409
+ comparableRuns,
2410
+ (run) => run.negotiatedProtocol || '',
2411
+ 'repeat-protocol-drift'
2412
+ );
2413
+ drift.capabilities = compareSetDrift(
2414
+ comparableRuns,
2415
+ (run) => run.capabilityKeys ?? advertisedCapabilityKeys(run.capabilityChecks),
2416
+ 'repeat-capability-drift'
2417
+ );
2418
+ drift.tools = compareToolDrift(comparableRuns);
2419
+ drift.lists.resources = compareListShapeDrift(comparableRuns, 'resources');
2420
+ drift.lists.prompts = compareListShapeDrift(comparableRuns, 'prompts');
2421
+
2422
+ const sections = [
2423
+ drift.negotiatedProtocol,
2424
+ drift.capabilities,
2425
+ drift.tools,
2426
+ drift.lists.resources,
2427
+ drift.lists.prompts
2428
+ ];
2429
+ const active = sections.filter((section) => section.status !== 'skipped');
2430
+ drift.status = active.length
2431
+ ? active.some((section) => section.status === 'warning')
2432
+ ? 'warning'
2433
+ : 'pass'
2434
+ : 'skipped';
2435
+ drift.issueCodes = [...new Set(active.flatMap((section) => section.issueCodes))].sort();
2436
+ return drift;
2437
+ }
2438
+
2439
+ function compareScalarDrift(runs, valueForRun, issueCode) {
2440
+ const section = driftSection();
2441
+ section.values = runs.map((run) => ({
2442
+ run: run.run,
2443
+ value: valueForRun(run)
2444
+ }));
2445
+ section.baseline = section.values[0].value;
2446
+ section.changedRuns = section.values
2447
+ .slice(1)
2448
+ .filter((entry) => entry.value !== section.baseline)
2449
+ .map((entry) => ({
2450
+ run: entry.run,
2451
+ expected: section.baseline,
2452
+ actual: entry.value
2453
+ }));
2454
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
2455
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
2456
+ return section;
2457
+ }
2458
+
2459
+ function compareSetDrift(runs, valuesForRun, issueCode) {
2460
+ const section = driftSection();
2461
+ section.values = runs.map((run) => ({
2462
+ run: run.run,
2463
+ values: sortedUnique(valuesForRun(run))
2464
+ }));
2465
+ section.baseline = section.values[0].values;
2466
+ section.changedRuns = section.values
2467
+ .slice(1)
2468
+ .map((entry) => ({
2469
+ run: entry.run,
2470
+ added: arrayDifference(entry.values, section.baseline),
2471
+ removed: arrayDifference(section.baseline, entry.values)
2472
+ }))
2473
+ .filter((entry) => entry.added.length || entry.removed.length);
2474
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
2475
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
2476
+ return section;
2477
+ }
2478
+
2479
+ function compareToolDrift(runs) {
2480
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
2481
+ if (checkedRuns.length < 2) {
2482
+ return driftSection();
2483
+ }
2484
+
2485
+ const section = compareSetDrift(
2486
+ checkedRuns,
2487
+ (run) => run.toolSchema.toolNames ?? [],
2488
+ 'repeat-tool-drift'
2489
+ );
2490
+ section.values = checkedRuns.map((run) => ({
2491
+ run: run.run,
2492
+ count: run.toolSchema.toolCount,
2493
+ values: sortedUnique(run.toolSchema.toolNames ?? [])
2494
+ }));
2495
+ section.baselineCount = section.values[0].count;
2496
+ const countDrift = section.values
2497
+ .slice(1)
2498
+ .filter((entry) => entry.count !== section.baselineCount)
2499
+ .map((entry) => ({
2500
+ run: entry.run,
2501
+ expected: section.baselineCount,
2502
+ actual: entry.count
2503
+ }));
2504
+ if (countDrift.length) {
2505
+ section.countChangedRuns = countDrift;
2506
+ section.status = 'warning';
2507
+ section.issueCodes = ['repeat-tool-drift'];
2508
+ }
2509
+ return section;
2510
+ }
2511
+
2512
+ function compareListShapeDrift(runs, capability) {
2513
+ const checkedRuns = runs.filter((run) => Number.isInteger(run.capabilityChecks?.[capability]?.itemCount));
2514
+ if (checkedRuns.length < 2) {
2515
+ return driftSection();
2516
+ }
2517
+
2518
+ const section = compareScalarDrift(
2519
+ checkedRuns,
2520
+ (run) => run.capabilityChecks[capability].itemCount,
2521
+ 'repeat-list-shape-drift'
2522
+ );
2523
+ section.capability = capability;
2524
+ return section;
2525
+ }
2526
+
2527
+ function repeatDriftIssues(drift) {
2528
+ if (!drift?.checked || drift.status !== 'warning') {
2529
+ return [];
2530
+ }
2531
+
2532
+ const issues = [];
2533
+ if (drift.negotiatedProtocol.status === 'warning') {
2534
+ issues.push(repeatDriftIssue('repeat-protocol-drift', 'negotiated protocol changed across repeat runs', {
2535
+ drift: drift.negotiatedProtocol
2536
+ }));
2537
+ }
2538
+ if (drift.capabilities.status === 'warning') {
2539
+ issues.push(repeatDriftIssue('repeat-capability-drift', 'advertised capability keys changed across repeat runs', {
2540
+ drift: drift.capabilities
2541
+ }));
2542
+ }
2543
+ if (drift.tools.status === 'warning') {
2544
+ issues.push(repeatDriftIssue('repeat-tool-drift', 'tools/list tool names or counts changed across repeat runs', {
2545
+ drift: drift.tools
2546
+ }));
2547
+ }
2548
+ for (const capability of ['resources', 'prompts']) {
2549
+ if (drift.lists[capability].status === 'warning') {
2550
+ issues.push(repeatDriftIssue('repeat-list-shape-drift', `${capability}/list item count changed across repeat runs`, {
2551
+ capability,
2552
+ drift: drift.lists[capability]
2553
+ }));
2554
+ }
2555
+ }
2556
+ return issues;
2557
+ }
2558
+
2559
+ function repeatDriftIssue(code, message, details) {
2560
+ return {
2561
+ severity: 'warning',
2562
+ code,
2563
+ message,
2564
+ ...details
2565
+ };
2566
+ }
2567
+
2568
+ function advertisedCapabilityKeys(capabilityChecks = {}) {
2569
+ return Object.entries(capabilityChecks)
2570
+ .filter(([, check]) => check?.advertised)
2571
+ .map(([name]) => name)
2572
+ .sort();
2573
+ }
2574
+
2575
+ function sortedUnique(values) {
2576
+ return [...new Set((values ?? []).filter((value) => typeof value === 'string'))].sort();
2577
+ }
2578
+
2579
+ function arrayDifference(left, right) {
2580
+ const rightSet = new Set(right);
2581
+ return left.filter((value) => !rightSet.has(value));
2582
+ }
2583
+
645
2584
  export function scanSource(root) {
646
2585
  const findings = [];
647
2586
  const absoluteRoot = path.resolve(root);
@@ -651,12 +2590,14 @@ export function scanSource(root) {
651
2590
  const text = fs.readFileSync(file, 'utf8');
652
2591
  const lines = text.split(/\r?\n/);
653
2592
  for (let index = 0; index < lines.length; index += 1) {
654
- const message = detectStdoutWrite(file, lines[index]);
655
- if (message) {
2593
+ const finding = detectStdoutRisk(file, lines[index]);
2594
+ if (finding) {
656
2595
  findings.push({
657
2596
  file: path.relative(process.cwd(), file).split(path.sep).join('/'),
658
2597
  line: index + 1,
659
- message
2598
+ language: finding.language,
2599
+ reason: finding.reason,
2600
+ message: finding.message
660
2601
  });
661
2602
  }
662
2603
  }
@@ -665,34 +2606,254 @@ export function scanSource(root) {
665
2606
  return findings;
666
2607
  }
667
2608
 
668
- function detectStdoutWrite(file, line) {
669
- const ext = path.extname(file);
2609
+ function detectStdoutRisk(file, line) {
2610
+ const ext = path.extname(file).toLowerCase();
670
2611
  const stripped = line.trim();
671
2612
  if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
2613
+ const code = normalizeSourceLine(line, ext);
2614
+
2615
+ if (isJavaScriptSource(ext)) {
2616
+ if (/\bconsole\.(log|info)\s*\(/.test(code)) {
2617
+ return staticFinding(
2618
+ 'javascript',
2619
+ 'javascript-console-stdout',
2620
+ 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics'
2621
+ );
2622
+ }
2623
+
2624
+ if (/\bprocess\.stdout\.write\s*\(/.test(code)) {
2625
+ return staticFinding(
2626
+ 'javascript',
2627
+ 'javascript-process-stdout-write',
2628
+ 'direct process.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
2629
+ );
2630
+ }
2631
+
2632
+ if (/\bprocess\.stdout\.(clearLine|cursorTo|moveCursor)\s*\(/.test(code)) {
2633
+ return staticFinding(
2634
+ 'javascript',
2635
+ 'javascript-stdout-terminal-control',
2636
+ 'process.stdout terminal control writes to stdout; keep MCP stdout reserved for JSON-RPC frames'
2637
+ );
2638
+ }
672
2639
 
673
- if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext) && /\bconsole\.(log|info)\s*\(/.test(line)) {
674
- return 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics';
2640
+ if (/\bdotenv\.config\s*\(\s*\{[^}]*\bdebug\s*:\s*true\b/.test(code)) {
2641
+ return staticFinding(
2642
+ 'javascript',
2643
+ 'javascript-dotenv-debug-output',
2644
+ 'dotenv debug output can print during startup; keep MCP diagnostics on stderr'
2645
+ );
2646
+ }
2647
+
2648
+ if (/\b(stream|file)\s*:\s*process\.stdout\b/.test(code)) {
2649
+ return staticFinding(
2650
+ 'javascript',
2651
+ 'javascript-progress-stdout',
2652
+ 'progress or spinner output is configured for stdout; MCP stdout must stay JSON-RPC only'
2653
+ );
2654
+ }
675
2655
  }
676
2656
 
677
- if (ext === '.py' && /(^|[^\w])print\s*\(/.test(line) && !/file\s*=\s*sys\.stderr/.test(line)) {
678
- return 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics';
2657
+ if (ext === '.py') {
2658
+ if (/(^|[^\w.])print\s*\(/.test(code) && !/file\s*=\s*sys\.stderr/.test(code)) {
2659
+ return staticFinding(
2660
+ 'python',
2661
+ 'python-print-stdout',
2662
+ 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics'
2663
+ );
2664
+ }
2665
+
2666
+ if (/\bsys\.stdout\.write\s*\(/.test(code)) {
2667
+ return staticFinding(
2668
+ 'python',
2669
+ 'python-sys-stdout-write',
2670
+ 'direct sys.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
2671
+ );
2672
+ }
2673
+
2674
+ if (/\blogging\.StreamHandler\s*\(\s*sys\.stdout\s*\)/.test(code)
2675
+ || /\blogging\.basicConfig\s*\([^)]*\bstream\s*=\s*sys\.stdout\b/.test(code)) {
2676
+ return staticFinding(
2677
+ 'python',
2678
+ 'python-logging-stdout-handler',
2679
+ 'Python logging is configured to write to stdout; route MCP diagnostics to stderr'
2680
+ );
2681
+ }
2682
+
2683
+ if (/\btqdm\s*\([^)]*\bfile\s*=\s*sys\.stdout\b/.test(code)) {
2684
+ return staticFinding(
2685
+ 'python',
2686
+ 'python-progress-stdout',
2687
+ 'progress output is configured for stdout; MCP stdout must stay JSON-RPC only'
2688
+ );
2689
+ }
679
2690
  }
680
2691
 
681
- if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(line)) {
682
- return 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics';
2692
+ if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(code)) {
2693
+ return staticFinding(
2694
+ 'go',
2695
+ 'go-fmt-stdout',
2696
+ 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics'
2697
+ );
683
2698
  }
684
2699
 
685
- if (ext === '.rs' && /\bprintln!\s*\(/.test(line)) {
686
- return 'println! writes to stdout; use eprintln! for MCP stdio diagnostics';
2700
+ if (ext === '.rs' && /\bprintln!\s*\(/.test(code)) {
2701
+ return staticFinding(
2702
+ 'rust',
2703
+ 'rust-println-stdout',
2704
+ 'println! writes to stdout; use eprintln! for MCP stdio diagnostics'
2705
+ );
687
2706
  }
688
2707
 
689
- if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(line)) {
690
- return 'System.out writes to stdout; use stderr for MCP stdio diagnostics';
2708
+ if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(code)) {
2709
+ return staticFinding(
2710
+ 'jvm',
2711
+ 'jvm-system-out',
2712
+ 'System.out writes to stdout; use stderr for MCP stdio diagnostics'
2713
+ );
691
2714
  }
692
2715
 
693
2716
  return '';
694
2717
  }
695
2718
 
2719
+ function staticFinding(language, reason, message) {
2720
+ return { language, reason, message };
2721
+ }
2722
+
2723
+ function isJavaScriptSource(ext) {
2724
+ return ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext);
2725
+ }
2726
+
2727
+ function normalizeSourceLine(line, ext) {
2728
+ return stripInlineComment(maskStringLiterals(line), ext);
2729
+ }
2730
+
2731
+ function stripInlineComment(line, ext) {
2732
+ if (ext === '.py') {
2733
+ return line.split('#')[0];
2734
+ }
2735
+
2736
+ if (isJavaScriptSource(ext) || ['.go', '.rs', '.java', '.kt'].includes(ext)) {
2737
+ return stripJavaScriptLikeInlineComment(line);
2738
+ }
2739
+
2740
+ return line;
2741
+ }
2742
+
2743
+ function stripJavaScriptLikeInlineComment(line) {
2744
+ let inRegex = false;
2745
+ let escaped = false;
2746
+ let inCharClass = false;
2747
+
2748
+ for (let index = 0; index < line.length; index += 1) {
2749
+ const char = line[index];
2750
+ const next = line[index + 1];
2751
+
2752
+ if (inRegex) {
2753
+ if (escaped) {
2754
+ escaped = false;
2755
+ } else if (char === '\\') {
2756
+ escaped = true;
2757
+ } else if (char === '[') {
2758
+ inCharClass = true;
2759
+ } else if (char === ']') {
2760
+ inCharClass = false;
2761
+ } else if (char === '/' && !inCharClass) {
2762
+ inRegex = false;
2763
+ }
2764
+ continue;
2765
+ }
2766
+
2767
+ if (char === '/' && next === '/') {
2768
+ return line.slice(0, index);
2769
+ }
2770
+
2771
+ if (char === '/' && next === '*') {
2772
+ return line.slice(0, index);
2773
+ }
2774
+
2775
+ if (char === '/' && canStartJavaScriptRegex(line, index)) {
2776
+ inRegex = true;
2777
+ }
2778
+ }
2779
+
2780
+ return line;
2781
+ }
2782
+
2783
+ function canStartJavaScriptRegex(line, index) {
2784
+ const before = line.slice(0, index).trimEnd();
2785
+ if (!before) return true;
2786
+
2787
+ const last = before[before.length - 1];
2788
+ if ('=(:,[!&|?;{}>'.includes(last)) return true;
2789
+
2790
+ return /\b(await|case|delete|of|return|throw|typeof|void|yield)$/.test(before);
2791
+ }
2792
+
2793
+ function maskStringLiterals(line) {
2794
+ let quote = '';
2795
+ let escaped = false;
2796
+ let masked = '';
2797
+ let templateExpressionDepth = 0;
2798
+
2799
+ for (let index = 0; index < line.length; index += 1) {
2800
+ const char = line[index];
2801
+ const next = line[index + 1];
2802
+
2803
+ if (quote) {
2804
+ if (quote === '`' && char === '$' && next === '{') {
2805
+ quote = '';
2806
+ templateExpressionDepth += 1;
2807
+ masked += ' ';
2808
+ index += 1;
2809
+ continue;
2810
+ }
2811
+
2812
+ if (escaped) {
2813
+ escaped = false;
2814
+ } else if (char === '\\') {
2815
+ escaped = true;
2816
+ } else if (char === quote) {
2817
+ quote = '';
2818
+ }
2819
+ masked += ' ';
2820
+ continue;
2821
+ }
2822
+
2823
+ if (templateExpressionDepth > 0) {
2824
+ if (char === '"' || char === "'" || char === '`') {
2825
+ quote = char;
2826
+ masked += ' ';
2827
+ continue;
2828
+ }
2829
+
2830
+ if (char === '{') {
2831
+ templateExpressionDepth += 1;
2832
+ } else if (char === '}') {
2833
+ templateExpressionDepth -= 1;
2834
+ if (templateExpressionDepth === 0) {
2835
+ quote = '`';
2836
+ masked += ' ';
2837
+ continue;
2838
+ }
2839
+ }
2840
+
2841
+ masked += char;
2842
+ continue;
2843
+ }
2844
+
2845
+ if (char === '"' || char === "'" || char === '`') {
2846
+ quote = char;
2847
+ masked += ' ';
2848
+ continue;
2849
+ }
2850
+
2851
+ masked += char;
2852
+ }
2853
+
2854
+ return masked;
2855
+ }
2856
+
696
2857
  function listSourceFiles(root) {
697
2858
  const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
698
2859
  const files = [];
@@ -738,6 +2899,10 @@ function formatTextResult(result) {
738
2899
  lines.push(`request: ${result.operation.method} ${state}`);
739
2900
  }
740
2901
 
2902
+ if (result.toolSchema?.checked) {
2903
+ lines.push(`tool schemas: ${result.toolSchema.validToolCount}/${result.toolSchema.toolCount} valid`);
2904
+ }
2905
+
741
2906
  if (result.staticFindings.length) {
742
2907
  lines.push(`static findings: ${result.staticFindings.length}`);
743
2908
  for (const finding of result.staticFindings.slice(0, 10)) {
@@ -760,11 +2925,19 @@ function formatRepeatedTextResult(result) {
760
2925
  `runs: ${passedRuns}/${result.runs.length} passed`
761
2926
  ];
762
2927
 
2928
+ if (result.drift?.checked && result.drift.status !== 'skipped') {
2929
+ const issueCodes = result.drift.issueCodes.length ? ` (${result.drift.issueCodes.join(', ')})` : '';
2930
+ lines.push(`drift: ${result.drift.status}${issueCodes}`);
2931
+ }
2932
+
763
2933
  for (const run of result.runs) {
764
2934
  const runStatus = run.ok ? 'PASS' : 'FAIL';
765
2935
  const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
766
2936
  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`);
2937
+ const toolSchemas = run.toolSchema?.checked
2938
+ ? `, tool schemas ${run.toolSchema.validToolCount}/${run.toolSchema.toolCount} valid`
2939
+ : '';
2940
+ lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines${toolSchemas}`);
768
2941
  }
769
2942
 
770
2943
  if (result.staticFindings.length) {
@@ -812,6 +2985,8 @@ Usage:
812
2985
  mcp-stdio-guard [options] -- <command> [args...]
813
2986
 
814
2987
  Options:
2988
+ --config <path> read JSON config for registry runs and safe tool calls
2989
+ --profile <name> guard profile: custom, smoke, registry, ci, strict
815
2990
  --protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
816
2991
  --timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
817
2992
  --repeat <count> run the guard multiple times, default 1