mcp-stdio-guard 0.3.0 → 0.5.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 +176 -14
  2. package/package.json +1 -1
  3. package/src/index.js +2210 -103
package/src/index.js CHANGED
@@ -6,17 +6,36 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  function loadVersion() {
7
7
  try {
8
8
  const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
- return typeof packageJson.version === 'string' ? packageJson.version : '0.3.0';
9
+ return typeof packageJson.version === 'string' ? packageJson.version : '0.5.0';
10
10
  } catch {
11
- return '0.3.0';
11
+ return '0.5.0';
12
12
  }
13
13
  }
14
14
 
15
15
  const DEFAULT_PROTOCOL = '2025-11-25';
16
16
  const DEFAULT_TIMEOUT = 5000;
17
+ const DEFAULT_PROFILE = 'custom';
17
18
  const VERSION = loadVersion();
18
19
  const JSON_SCHEMA_VERSION = 1;
19
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 with opt-in adversarial protocol probes'
36
+ }
37
+ });
38
+ const GUARD_PROFILE_NAMES = Object.keys(GUARD_PROFILES);
20
39
  const VERSION_PROBE_CACHE = new Map();
21
40
  const NODE_OPTIONS_WITH_VALUES = new Set([
22
41
  '--conditions',
@@ -70,6 +89,80 @@ const JSON_RPC_ISSUE_CODES = new Set([
70
89
  'stdout-invalid-json-rpc',
71
90
  'stdout-unexpected-request-id'
72
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 ADVERSARIAL_OBSERVATION_MS = 150;
98
+ const BUILTIN_ADVERSARIAL_PROBES = Object.freeze({
99
+ 'invalid-method': {
100
+ name: 'invalid-method',
101
+ source: 'builtin',
102
+ method: 'mcp_stdio_guard/invalid_method',
103
+ params: { reason: 'adversarial-probe' },
104
+ expectation: 'error',
105
+ failureCode: 'adversarial-invalid-method-result',
106
+ description: 'invalid method requests return structured JSON-RPC errors',
107
+ risk: 'Sends one unknown JSON-RPC method after initialize; servers should answer with an error instead of crashing or returning success.'
108
+ },
109
+ 'invalid-params': {
110
+ name: 'invalid-params',
111
+ source: 'builtin',
112
+ method: 'tools/list',
113
+ params: [],
114
+ expectation: 'error',
115
+ failureCode: 'adversarial-invalid-params-result',
116
+ description: 'invalid params return structured JSON-RPC errors',
117
+ risk: 'Sends deliberately invalid params to a common MCP method; tolerant servers may need to opt out of this stricter probe.'
118
+ },
119
+ notification: {
120
+ name: 'notification',
121
+ source: 'builtin',
122
+ method: 'notifications/mcp_stdio_guard_probe',
123
+ params: { reason: 'adversarial-probe' },
124
+ omitId: true,
125
+ expectation: 'no-response',
126
+ failureCode: 'adversarial-notification-response',
127
+ description: 'notifications do not receive responses',
128
+ risk: 'Sends one unknown JSON-RPC notification; servers should not reply to notifications even when the method is unknown.'
129
+ },
130
+ 'malformed-json': {
131
+ name: 'malformed-json',
132
+ source: 'builtin',
133
+ method: '',
134
+ rawLine: '{"jsonrpc":"2.0","id":"mcp-stdio-guard-malformed","method":',
135
+ expectation: 'error-or-no-response',
136
+ failureCode: 'adversarial-malformed-json-result',
137
+ description: 'malformed JSON does not crash the server',
138
+ risk: 'Writes one malformed line to stdin; servers may return a structured parse error or ignore it, but should not crash.'
139
+ }
140
+ });
141
+ const BUILTIN_ADVERSARIAL_PROBE_NAMES = Object.freeze(Object.keys(BUILTIN_ADVERSARIAL_PROBES));
142
+ const STRICT_ADVERSARIAL_PROBE_NAMES = BUILTIN_ADVERSARIAL_PROBE_NAMES;
143
+ const SUPPORTED_ADVERSARIAL_EXPECTATIONS = new Set(['error', 'no-response', 'error-or-no-response']);
144
+ const CAPABILITY_ISSUE_CODES = new Set([
145
+ 'capability-list-error',
146
+ 'capability-list-missing-response',
147
+ 'capability-list-timeout',
148
+ 'capability-list-unsupported'
149
+ ]);
150
+ const ADVERSARIAL_ISSUE_CODES = new Set([
151
+ 'adversarial-invalid-method-result',
152
+ 'adversarial-invalid-params-result',
153
+ 'adversarial-malformed-json-result',
154
+ 'adversarial-notification-response',
155
+ 'adversarial-probe-crash',
156
+ 'adversarial-probe-invalid-stdout',
157
+ 'adversarial-probe-timeout',
158
+ 'adversarial-tool-call-result'
159
+ ]);
160
+ const REPEAT_DRIFT_ISSUE_CODES = new Set([
161
+ 'repeat-capability-drift',
162
+ 'repeat-list-shape-drift',
163
+ 'repeat-protocol-drift',
164
+ 'repeat-tool-drift'
165
+ ]);
73
166
  const INITIALIZE_ISSUE_CODES = new Set([
74
167
  'initialize-error',
75
168
  'initialize-invalid-capabilities',
@@ -88,6 +181,15 @@ const OPERATION_ISSUE_CODES = new Set([
88
181
  'operation-missing-response',
89
182
  'operation-timeout'
90
183
  ]);
184
+ const TOOL_SCHEMA_ISSUE_CODES = new Set([
185
+ 'tool-description-missing',
186
+ 'tool-input-schema-invalid',
187
+ 'tool-input-schema-required-missing',
188
+ 'tool-name-duplicate',
189
+ 'tool-name-invalid',
190
+ 'tools-list-invalid-result'
191
+ ]);
192
+ const JSON_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
91
193
  const PROCESS_ISSUE_CODES = new Set([
92
194
  'server-crashed',
93
195
  'server-exited',
@@ -117,6 +219,28 @@ const ISSUE_CLASS_BY_CODE = new Map([
117
219
  ['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
118
220
  ['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
119
221
  ['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
222
+ ['capability-list-error', ISSUE_CLASSES.MCP_PROTOCOL],
223
+ ['capability-list-missing-response', ISSUE_CLASSES.MCP_PROTOCOL],
224
+ ['capability-list-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
225
+ ['capability-list-unsupported', ISSUE_CLASSES.MCP_PROTOCOL],
226
+ ['repeat-capability-drift', ISSUE_CLASSES.MCP_PROTOCOL],
227
+ ['repeat-list-shape-drift', ISSUE_CLASSES.MCP_PROTOCOL],
228
+ ['repeat-protocol-drift', ISSUE_CLASSES.MCP_PROTOCOL],
229
+ ['repeat-tool-drift', ISSUE_CLASSES.MCP_PROTOCOL],
230
+ ['tool-description-missing', ISSUE_CLASSES.MCP_PROTOCOL],
231
+ ['tool-input-schema-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
232
+ ['tool-input-schema-required-missing', ISSUE_CLASSES.MCP_PROTOCOL],
233
+ ['tool-name-duplicate', ISSUE_CLASSES.MCP_PROTOCOL],
234
+ ['tool-name-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
235
+ ['tools-list-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
236
+ ['adversarial-invalid-method-result', ISSUE_CLASSES.MCP_PROTOCOL],
237
+ ['adversarial-invalid-params-result', ISSUE_CLASSES.MCP_PROTOCOL],
238
+ ['adversarial-malformed-json-result', ISSUE_CLASSES.MCP_PROTOCOL],
239
+ ['adversarial-notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
240
+ ['adversarial-probe-crash', ISSUE_CLASSES.MCP_PROTOCOL],
241
+ ['adversarial-probe-invalid-stdout', ISSUE_CLASSES.MCP_PROTOCOL],
242
+ ['adversarial-probe-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
243
+ ['adversarial-tool-call-result', ISSUE_CLASSES.MCP_PROTOCOL],
120
244
  ['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
121
245
  ['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
122
246
  ['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
@@ -142,13 +266,19 @@ export async function runCli(argv) {
142
266
  }
143
267
 
144
268
  const guardOptions = {
269
+ config: options.config,
270
+ profile: options.profile,
145
271
  protocol: options.protocol,
146
272
  timeoutMs: options.timeoutMs,
147
273
  cwd: options.cwd,
148
- operation: options.requestMethod
274
+ env: options.env,
275
+ probeCapabilities: options.probeCapabilities,
276
+ adversarialProbes: options.adversarialProbes,
277
+ operations: options.operations,
278
+ operation: options.operations.length === 1
149
279
  ? {
150
- method: options.requestMethod,
151
- params: options.requestParams
280
+ method: options.operations[0].method,
281
+ params: options.operations[0].params
152
282
  }
153
283
  : null
154
284
  };
@@ -194,8 +324,17 @@ export async function runCli(argv) {
194
324
  export function parseArgs(argv) {
195
325
  const options = {
196
326
  command: [],
327
+ configPath: '',
328
+ config: defaultConfigMetadata(),
329
+ env: {},
330
+ operations: [],
331
+ configOperations: [],
332
+ adversarialProbeSpecs: [],
333
+ adversarialProbes: [],
197
334
  protocol: DEFAULT_PROTOCOL,
198
335
  timeoutMs: DEFAULT_TIMEOUT,
336
+ profile: DEFAULT_PROFILE,
337
+ probeCapabilities: true,
199
338
  scanPath: '',
200
339
  failOnStatic: false,
201
340
  requestMethod: '',
@@ -206,12 +345,14 @@ export function parseArgs(argv) {
206
345
  version: false,
207
346
  cwd: process.cwd()
208
347
  };
348
+ const specifiedOptions = new Set();
209
349
 
210
350
  for (let index = 0; index < argv.length; index += 1) {
211
351
  const arg = argv[index];
212
352
 
213
353
  if (arg === '--') {
214
354
  options.command = argv.slice(index + 1);
355
+ specifiedOptions.add('command');
215
356
  break;
216
357
  }
217
358
 
@@ -221,34 +362,66 @@ export function parseArgs(argv) {
221
362
  options.version = true;
222
363
  } else if (arg === '--json') {
223
364
  options.json = true;
365
+ specifiedOptions.add('json');
366
+ } else if (arg === '--config') {
367
+ options.configPath = path.resolve(readOptionValue(argv, index, arg));
368
+ specifiedOptions.add('configPath');
369
+ index += 1;
370
+ } else if (arg === '--profile') {
371
+ options.profile = readOptionValue(argv, index, arg);
372
+ specifiedOptions.add('profile');
373
+ index += 1;
224
374
  } else if (arg === '--fail-on-static') {
225
375
  options.failOnStatic = true;
376
+ specifiedOptions.add('failOnStatic');
226
377
  } else if (arg === '--request') {
227
378
  options.requestMethod = readOptionValue(argv, index, arg);
379
+ specifiedOptions.add('requestMethod');
228
380
  index += 1;
229
381
  } else if (arg === '--params') {
230
382
  options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
383
+ specifiedOptions.add('requestParams');
384
+ index += 1;
385
+ } else if (arg === '--adversarial-probe' || arg === '--adversarial-probes') {
386
+ options.adversarialProbeSpecs.push(...expandAdversarialProbeList(readOptionValue(argv, index, arg), arg));
387
+ specifiedOptions.add('adversarialProbes');
231
388
  index += 1;
232
389
  } else if (arg === '--protocol') {
233
390
  options.protocol = readOptionValue(argv, index, arg);
391
+ specifiedOptions.add('protocol');
234
392
  index += 1;
235
393
  } else if (arg === '--timeout') {
236
394
  options.timeoutMs = Number(readOptionValue(argv, index, arg));
395
+ specifiedOptions.add('timeoutMs');
237
396
  index += 1;
238
397
  } else if (arg === '--repeat') {
239
398
  options.repeat = Number(readOptionValue(argv, index, arg));
399
+ specifiedOptions.add('repeat');
240
400
  index += 1;
241
401
  } else if (arg === '--scan') {
242
402
  options.scanPath = path.resolve(readOptionValue(argv, index, arg));
403
+ specifiedOptions.add('scanPath');
243
404
  index += 1;
244
405
  } else if (arg === '--cwd') {
245
406
  options.cwd = path.resolve(readOptionValue(argv, index, arg));
407
+ specifiedOptions.add('cwd');
246
408
  index += 1;
247
409
  } else {
248
410
  throw new Error(`Unknown option before --: ${arg}`);
249
411
  }
250
412
  }
251
413
 
414
+ if (options.help || options.version) {
415
+ return options;
416
+ }
417
+
418
+ if (options.configPath) {
419
+ applyConfigFile(options, loadConfigFile(options.configPath), specifiedOptions);
420
+ }
421
+ applyProfileDefaults(options, specifiedOptions);
422
+ options.operations = buildConfiguredOperations(options);
423
+ options.adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbeSpecs);
424
+
252
425
  if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
253
426
  throw new Error('--timeout must be an integer >= 100');
254
427
  }
@@ -264,9 +437,475 @@ export function parseArgs(argv) {
264
437
  return options;
265
438
  }
266
439
 
440
+ function applyProfileDefaults(options, specifiedOptions) {
441
+ if (!GUARD_PROFILE_NAMES.includes(options.profile)) {
442
+ throw new Error(`--profile must be one of: ${GUARD_PROFILE_NAMES.join(', ')}`);
443
+ }
444
+
445
+ if (options.profile === 'smoke') {
446
+ options.probeCapabilities = false;
447
+ return options;
448
+ }
449
+
450
+ options.probeCapabilities = true;
451
+
452
+ if (options.profile === 'registry' && !specifiedOptions.has('repeat')) {
453
+ options.repeat = 2;
454
+ }
455
+
456
+ if (options.profile === 'ci') {
457
+ options.json = true;
458
+ options.failOnStatic = true;
459
+ }
460
+
461
+ if (options.profile === 'strict') {
462
+ options.json = true;
463
+ options.failOnStatic = true;
464
+ if (!specifiedOptions.has('repeat')) {
465
+ options.repeat = 2;
466
+ }
467
+ if (!specifiedOptions.has('adversarialProbes')) {
468
+ options.adversarialProbeSpecs.push(...STRICT_ADVERSARIAL_PROBE_NAMES);
469
+ }
470
+ }
471
+
472
+ return options;
473
+ }
474
+
475
+ function loadConfigFile(configPath) {
476
+ let raw;
477
+ try {
478
+ raw = fs.readFileSync(configPath, 'utf8');
479
+ } catch (error) {
480
+ throw new Error(`Failed to read --config ${configPath}: ${error.message}`);
481
+ }
482
+
483
+ try {
484
+ const parsed = JSON.parse(raw);
485
+ if (!isObjectRecord(parsed)) {
486
+ throw new Error('top-level value must be an object');
487
+ }
488
+ return parsed;
489
+ } catch (error) {
490
+ throw new Error(`--config ${configPath} must be valid JSON: ${error.message}`);
491
+ }
492
+ }
493
+
494
+ function applyConfigFile(options, config, specifiedOptions) {
495
+ const configDir = path.dirname(options.configPath);
496
+ const command = normalizeConfigCommand(config);
497
+ const env = normalizeConfigEnv(config);
498
+ const requests = normalizeConfigRequests(config);
499
+ const safeToolCalls = normalizeSafeToolCalls(config);
500
+ const adversarialProbes = normalizeConfigAdversarialProbes(config);
501
+ const adversarialToolCalls = normalizeConfigAdversarialToolCalls(config);
502
+ const usesConfigCommand = command.length > 0 && !specifiedOptions.has('command');
503
+ const usesConfigCwd = typeof config.cwd === 'string' && !specifiedOptions.has('cwd');
504
+ const usesConfigOperations = !specifiedOptions.has('requestMethod') && !specifiedOptions.has('requestParams');
505
+ const usesConfigAdversarialProbes = !specifiedOptions.has('adversarialProbes')
506
+ && (Object.hasOwn(config, 'adversarialProbes') || Object.hasOwn(config, 'adversarialToolCalls'));
507
+
508
+ if (usesConfigCommand) {
509
+ options.command = command;
510
+ }
511
+
512
+ if (usesConfigCwd) {
513
+ options.cwd = resolveConfigPath(config.cwd, configDir);
514
+ } else if (config.cwd !== undefined && typeof config.cwd !== 'string') {
515
+ throw new Error('--config cwd must be a string');
516
+ }
517
+
518
+ if (config.protocol !== undefined && !specifiedOptions.has('protocol')) {
519
+ if (typeof config.protocol !== 'string' || !config.protocol) {
520
+ throw new Error('--config protocol must be a non-empty string');
521
+ }
522
+ options.protocol = config.protocol;
523
+ }
524
+
525
+ const timeoutField = Object.hasOwn(config, 'timeoutMs') ? 'timeoutMs' : 'timeout';
526
+ const timeoutValue = config[timeoutField];
527
+ if (timeoutValue !== undefined && !specifiedOptions.has('timeoutMs')) {
528
+ options.timeoutMs = normalizeConfigInteger(timeoutValue, timeoutField, 100);
529
+ }
530
+
531
+ if (config.repeat !== undefined && !specifiedOptions.has('repeat')) {
532
+ options.repeat = normalizeConfigInteger(config.repeat, 'repeat', 1);
533
+ }
534
+
535
+ if (config.profile !== undefined && !specifiedOptions.has('profile')) {
536
+ if (typeof config.profile !== 'string') {
537
+ throw new Error('--config profile must be a string');
538
+ }
539
+ options.profile = config.profile;
540
+ }
541
+
542
+ if (config.json !== undefined && !specifiedOptions.has('json')) {
543
+ if (typeof config.json !== 'boolean') {
544
+ throw new Error('--config json must be a boolean');
545
+ }
546
+ options.json = config.json;
547
+ }
548
+
549
+ const scanPath = config.scanPath ?? config.scan;
550
+ if (scanPath !== undefined && !specifiedOptions.has('scanPath')) {
551
+ if (typeof scanPath !== 'string') {
552
+ throw new Error('--config scan must be a string path');
553
+ }
554
+ options.scanPath = resolveConfigPath(scanPath, configDir);
555
+ }
556
+
557
+ if (config.failOnStatic !== undefined && !specifiedOptions.has('failOnStatic')) {
558
+ if (typeof config.failOnStatic !== 'boolean') {
559
+ throw new Error('--config failOnStatic must be a boolean');
560
+ }
561
+ options.failOnStatic = config.failOnStatic;
562
+ }
563
+
564
+ if (Object.keys(env).length) {
565
+ options.env = { ...env, ...options.env };
566
+ }
567
+
568
+ if (usesConfigOperations) {
569
+ options.configOperations = [...requests, ...safeToolCalls];
570
+ }
571
+
572
+ if (usesConfigAdversarialProbes) {
573
+ options.adversarialProbeSpecs = [...adversarialProbes, ...adversarialToolCalls];
574
+ specifiedOptions.add('adversarialProbes');
575
+ }
576
+
577
+ options.config = {
578
+ enabled: true,
579
+ path: options.configPath,
580
+ resolvedPath: options.configPath,
581
+ checks: {
582
+ command: usesConfigCommand,
583
+ cwd: usesConfigCwd,
584
+ envNames: Object.keys(env).sort(),
585
+ requests: usesConfigOperations ? requests.map((request) => request.method) : [],
586
+ safeToolCalls: usesConfigOperations ? safeToolCalls.map((request) => request.safeToolCallName) : [],
587
+ adversarialProbes: usesConfigAdversarialProbes ? adversarialProbes.map((probe) => probe.name ?? probe) : [],
588
+ adversarialToolCalls: usesConfigAdversarialProbes ? adversarialToolCalls.map((probe) => probe.safeToolCallName) : []
589
+ }
590
+ };
591
+ }
592
+
593
+ function normalizeConfigCommand(config) {
594
+ if (config.command === undefined && config.args === undefined) return [];
595
+
596
+ if (Array.isArray(config.command)) {
597
+ if (!config.command.every((entry) => typeof entry === 'string' && entry)) {
598
+ throw new Error('--config command array entries must be non-empty strings');
599
+ }
600
+ if (config.args !== undefined) {
601
+ throw new Error('--config args cannot be used when command is an array');
602
+ }
603
+ return config.command;
604
+ }
605
+
606
+ if (typeof config.command === 'string' && config.command) {
607
+ const args = config.args ?? [];
608
+ if (!Array.isArray(args) || !args.every((entry) => typeof entry === 'string')) {
609
+ throw new Error('--config args must be an array of strings');
610
+ }
611
+ return [config.command, ...args];
612
+ }
613
+
614
+ throw new Error('--config command must be a non-empty string or array of strings');
615
+ }
616
+
617
+ function normalizeConfigEnv(config) {
618
+ if (config.env === undefined) return {};
619
+ if (!isObjectRecord(config.env)) {
620
+ throw new Error('--config env must be an object');
621
+ }
622
+
623
+ return Object.fromEntries(Object.entries(config.env).map(([name, value]) => {
624
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
625
+ throw new Error(`--config env contains invalid variable name: ${name}`);
626
+ }
627
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
628
+ throw new Error(`--config env.${name} must be a string, number, or boolean`);
629
+ }
630
+ return [name, String(value)];
631
+ }));
632
+ }
633
+
634
+ function normalizeConfigInteger(value, field, minimum) {
635
+ const normalized = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
636
+ if (!Number.isInteger(normalized) || normalized < minimum) {
637
+ throw new Error(`--config ${field} must be an integer >= ${minimum}`);
638
+ }
639
+ return normalized;
640
+ }
641
+
642
+ function normalizeConfigRequests(config) {
643
+ const values = [];
644
+ if (config.request !== undefined) {
645
+ values.push(config.request);
646
+ }
647
+ if (config.requests !== undefined) {
648
+ if (!Array.isArray(config.requests)) {
649
+ throw new Error('--config requests must be an array');
650
+ }
651
+ values.push(...config.requests);
652
+ }
653
+
654
+ return values.map((request, index) => {
655
+ if (!isObjectRecord(request)) {
656
+ throw new Error(`--config requests[${index}] must be an object`);
657
+ }
658
+ if (typeof request.method !== 'string' || !request.method) {
659
+ throw new Error(`--config requests[${index}].method must be a non-empty string`);
660
+ }
661
+ return {
662
+ source: 'config-request',
663
+ method: request.method,
664
+ params: request.params
665
+ };
666
+ });
667
+ }
668
+
669
+ function normalizeSafeToolCalls(config) {
670
+ if (config.safeToolCalls === undefined) return [];
671
+ if (!Array.isArray(config.safeToolCalls)) {
672
+ throw new Error('--config safeToolCalls must be an array');
673
+ }
674
+
675
+ return config.safeToolCalls.map((call, index) => {
676
+ if (!isObjectRecord(call)) {
677
+ throw new Error(`--config safeToolCalls[${index}] must be an object`);
678
+ }
679
+ if (typeof call.name !== 'string' || !call.name) {
680
+ throw new Error(`--config safeToolCalls[${index}].name must be a non-empty string`);
681
+ }
682
+ const argumentsValue = call.arguments ?? {};
683
+ if (!isObjectRecord(argumentsValue)) {
684
+ throw new Error(`--config safeToolCalls[${index}].arguments must be an object`);
685
+ }
686
+ return {
687
+ source: 'safe-tool-call',
688
+ method: 'tools/call',
689
+ params: {
690
+ name: call.name,
691
+ arguments: argumentsValue
692
+ },
693
+ safeToolCallName: call.name
694
+ };
695
+ });
696
+ }
697
+
698
+ function normalizeConfigAdversarialProbes(config) {
699
+ if (config.adversarialProbes === undefined) return [];
700
+
701
+ if (config.adversarialProbes === true) {
702
+ return [...BUILTIN_ADVERSARIAL_PROBE_NAMES];
703
+ }
704
+
705
+ if (config.adversarialProbes === false) {
706
+ return [];
707
+ }
708
+
709
+ if (typeof config.adversarialProbes === 'string') {
710
+ return expandAndValidateAdversarialProbeList(config.adversarialProbes, '--config adversarialProbes');
711
+ }
712
+
713
+ if (!Array.isArray(config.adversarialProbes)) {
714
+ throw new Error('--config adversarialProbes must be a boolean, string, or array of strings');
715
+ }
716
+
717
+ return config.adversarialProbes.flatMap((probe, index) => {
718
+ if (typeof probe !== 'string') {
719
+ throw new Error(`--config adversarialProbes[${index}] must be a string`);
720
+ }
721
+ return expandAndValidateAdversarialProbeList(probe, `--config adversarialProbes[${index}]`);
722
+ });
723
+ }
724
+
725
+ function normalizeConfigAdversarialToolCalls(config) {
726
+ if (config.adversarialToolCalls === undefined) return [];
727
+ if (!Array.isArray(config.adversarialToolCalls)) {
728
+ throw new Error('--config adversarialToolCalls must be an array');
729
+ }
730
+
731
+ return config.adversarialToolCalls.map((call, index) => {
732
+ if (!isObjectRecord(call)) {
733
+ throw new Error(`--config adversarialToolCalls[${index}] must be an object`);
734
+ }
735
+ if (typeof call.name !== 'string' || !call.name) {
736
+ throw new Error(`--config adversarialToolCalls[${index}].name must be a non-empty string`);
737
+ }
738
+ const argumentsValue = call.arguments ?? {};
739
+ if (!isObjectRecord(argumentsValue)) {
740
+ throw new Error(`--config adversarialToolCalls[${index}].arguments must be an object`);
741
+ }
742
+ return {
743
+ name: `safe-tool-invalid-args:${call.name}`,
744
+ source: 'adversarial-tool-call',
745
+ method: 'tools/call',
746
+ params: {
747
+ name: call.name,
748
+ arguments: argumentsValue
749
+ },
750
+ safeToolCallName: call.name,
751
+ expectation: 'error',
752
+ failureCode: 'adversarial-tool-call-result',
753
+ description: `tools/call for ${call.name} with invalid arguments returns a structured error`,
754
+ risk: 'Calls a configured safe tool with intentionally invalid arguments; only use for idempotent tools.'
755
+ };
756
+ });
757
+ }
758
+
759
+ function buildConfiguredOperations(options) {
760
+ if (options.requestMethod) {
761
+ return [{
762
+ source: 'cli-request',
763
+ method: options.requestMethod,
764
+ params: options.requestParams
765
+ }];
766
+ }
767
+
768
+ return options.configOperations;
769
+ }
770
+
771
+ function normalizeGuardOperations(operations) {
772
+ return operations.map((operation) => ({
773
+ source: operation.source ?? 'request',
774
+ method: operation.method,
775
+ params: operation.params,
776
+ safeToolCallName: operation.safeToolCallName ?? ''
777
+ }));
778
+ }
779
+
780
+ function expandAdversarialProbeList(rawValue, option) {
781
+ const names = String(rawValue)
782
+ .split(',')
783
+ .map((name) => name.trim())
784
+ .filter(Boolean);
785
+
786
+ if (!names.length) {
787
+ throw new Error(`${option} requires at least one probe name`);
788
+ }
789
+
790
+ if (names.includes('none')) {
791
+ if (names.length > 1) {
792
+ throw new Error(`${option} cannot combine none with other probes`);
793
+ }
794
+ return [];
795
+ }
796
+
797
+ if (names.includes('all')) {
798
+ if (names.length > 1) {
799
+ throw new Error(`${option} cannot combine all with other probes`);
800
+ }
801
+ return [...BUILTIN_ADVERSARIAL_PROBE_NAMES];
802
+ }
803
+
804
+ return names;
805
+ }
806
+
807
+ function expandAndValidateAdversarialProbeList(rawValue, option) {
808
+ return expandAdversarialProbeList(rawValue, option).map((name) => validateAdversarialProbeName(name, option));
809
+ }
810
+
811
+ function validateAdversarialProbeName(name, option) {
812
+ if (!BUILTIN_ADVERSARIAL_PROBES[name]) {
813
+ throw new Error(`${option} must be one of: ${[...BUILTIN_ADVERSARIAL_PROBE_NAMES, 'all', 'none'].join(', ')}`);
814
+ }
815
+ return name;
816
+ }
817
+
818
+ function normalizeAdversarialProbeSpecs(specs) {
819
+ const probes = [];
820
+ const seenBuiltins = new Set();
821
+
822
+ for (const spec of specs ?? []) {
823
+ if (typeof spec === 'string') {
824
+ for (const name of expandAdversarialProbeList(spec, '--adversarial-probe')) {
825
+ const definition = BUILTIN_ADVERSARIAL_PROBES[validateAdversarialProbeName(name, '--adversarial-probe')];
826
+ if (seenBuiltins.has(name)) continue;
827
+ seenBuiltins.add(name);
828
+ probes.push({ ...definition });
829
+ }
830
+ } else if (isObjectRecord(spec)) {
831
+ probes.push(normalizeAdversarialProbeObject(spec, '--adversarial-probe'));
832
+ } else {
833
+ throw new Error('--adversarial-probe entries must be strings or configured adversarial tool calls');
834
+ }
835
+ }
836
+
837
+ return probes.map((probe, index) => ({
838
+ index,
839
+ safeToolCallName: '',
840
+ rawLine: '',
841
+ omitId: false,
842
+ ...probe
843
+ }));
844
+ }
845
+
846
+ function normalizeAdversarialProbeObject(spec, option) {
847
+ if (typeof spec.name !== 'string' || !spec.name.trim()) {
848
+ throw new Error(`${option}.name must be a non-empty string`);
849
+ }
850
+
851
+ if (typeof spec.expectation !== 'string' || !SUPPORTED_ADVERSARIAL_EXPECTATIONS.has(spec.expectation)) {
852
+ throw new Error(`${option}.expectation must be one of: ${[...SUPPORTED_ADVERSARIAL_EXPECTATIONS].join(', ')}`);
853
+ }
854
+
855
+ if (typeof spec.failureCode !== 'string' || !spec.failureCode) {
856
+ throw new Error(`${option}.failureCode must be a non-empty string`);
857
+ }
858
+
859
+ const rawLine = spec.rawLine ?? '';
860
+ if (typeof rawLine !== 'string') {
861
+ throw new Error(`${option}.rawLine must be a string when provided`);
862
+ }
863
+
864
+ const method = spec.method ?? '';
865
+ if (typeof method !== 'string') {
866
+ throw new Error(`${option}.method must be a string when provided`);
867
+ }
868
+ if (!rawLine && !method) {
869
+ throw new Error(`${option}.method must be a non-empty string when rawLine is not set`);
870
+ }
871
+
872
+ if (spec.source !== undefined && typeof spec.source !== 'string') {
873
+ throw new Error(`${option}.source must be a string when provided`);
874
+ }
875
+ if (spec.safeToolCallName !== undefined && typeof spec.safeToolCallName !== 'string') {
876
+ throw new Error(`${option}.safeToolCallName must be a string when provided`);
877
+ }
878
+ if (spec.description !== undefined && typeof spec.description !== 'string') {
879
+ throw new Error(`${option}.description must be a string when provided`);
880
+ }
881
+ if (spec.risk !== undefined && typeof spec.risk !== 'string') {
882
+ throw new Error(`${option}.risk must be a string when provided`);
883
+ }
884
+ if (spec.omitId !== undefined && typeof spec.omitId !== 'boolean') {
885
+ throw new Error(`${option}.omitId must be a boolean when provided`);
886
+ }
887
+ if (spec.quietMs !== undefined && (!Number.isInteger(spec.quietMs) || spec.quietMs < 1)) {
888
+ throw new Error(`${option}.quietMs must be an integer >= 1 when provided`);
889
+ }
890
+
891
+ return {
892
+ ...spec,
893
+ name: spec.name.trim(),
894
+ source: spec.source ?? 'custom',
895
+ method,
896
+ rawLine,
897
+ safeToolCallName: spec.safeToolCallName ?? ''
898
+ };
899
+ }
900
+
901
+ function resolveConfigPath(value, configDir) {
902
+ return path.resolve(configDir, value);
903
+ }
904
+
267
905
  export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
268
906
  const startedAt = Date.now();
269
907
  const repeat = options.repeat ?? 1;
908
+ const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
270
909
  const runs = [];
271
910
  const issues = [];
272
911
 
@@ -274,7 +913,7 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
274
913
  throw new Error('repeat must be an integer >= 1');
275
914
  }
276
915
 
277
- const singleRunOptions = { ...options, repeat: 1 };
916
+ const singleRunOptions = { ...options, adversarialProbes, repeat: 1 };
278
917
 
279
918
  for (let index = 1; index <= repeat; index += 1) {
280
919
  const run = await guardStdioServer(commandWithArgs, singleRunOptions);
@@ -285,18 +924,29 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
285
924
  }
286
925
  }
287
926
 
927
+ const drift = buildRepeatDrift(runs);
928
+ for (const issue of repeatDriftIssues(drift)) {
929
+ issues.push(issue);
930
+ }
931
+
288
932
  const durationMs = Date.now() - startedAt;
289
933
  const result = {
290
934
  schemaVersion: JSON_SCHEMA_VERSION,
291
935
  ok: !issues.some((issue) => issue.severity === 'error'),
936
+ profile: options.profile ?? DEFAULT_PROFILE,
292
937
  command: commandWithArgs,
938
+ config: options.config ?? defaultConfigMetadata(),
293
939
  protocol: options.protocol ?? DEFAULT_PROTOCOL,
294
940
  repeat,
295
941
  runs,
296
942
  issues,
297
943
  checks: {},
944
+ capabilityProbes: options.probeCapabilities ?? true,
945
+ adversarial: aggregateRunAdversarial(runs, adversarialProbes),
946
+ drift,
298
947
  staticScan: defaultStaticScan(),
299
948
  staticFindings: [],
949
+ toolSchema: aggregateRunToolSchemaValidation(runs),
300
950
  durationMs,
301
951
  fingerprint: createFingerprint(commandWithArgs, options)
302
952
  };
@@ -310,7 +960,9 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
310
960
  const args = commandWithArgs.slice(1);
311
961
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
312
962
  const protocol = options.protocol ?? DEFAULT_PROTOCOL;
313
- const operation = options.operation || null;
963
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
964
+ const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
965
+ const probeCapabilities = options.probeCapabilities ?? true;
314
966
  const env = { ...process.env, ...(options.env ?? {}) };
315
967
  const issues = [];
316
968
  const frames = [];
@@ -319,36 +971,60 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
319
971
  let initialized = false;
320
972
  let endedByGuard = false;
321
973
  let initializeResponseAt = 0;
974
+ let currentRequest = null;
975
+ let requestQueue = [];
976
+ let nextRequestId = 2;
322
977
  let timer;
323
978
  let child;
324
979
 
325
980
  const result = {
326
981
  schemaVersion: JSON_SCHEMA_VERSION,
327
982
  ok: false,
983
+ profile: options.profile ?? DEFAULT_PROFILE,
328
984
  command: commandWithArgs,
985
+ config: options.config ?? defaultConfigMetadata(),
329
986
  protocol,
330
987
  negotiatedProtocol: '',
331
988
  initialized: false,
332
- operation: operation
989
+ operation: operations.length === 1
333
990
  ? {
334
- method: operation.method,
991
+ method: operations[0].method,
992
+ source: operations[0].source,
993
+ safeToolCallName: operations[0].safeToolCallName,
335
994
  responded: false,
336
995
  error: null
337
996
  }
338
997
  : null,
998
+ operations: operations.map((operation, index) => ({
999
+ index,
1000
+ source: operation.source,
1001
+ method: operation.method,
1002
+ safeToolCallName: operation.safeToolCallName,
1003
+ responded: false,
1004
+ error: null
1005
+ })),
339
1006
  frames,
340
1007
  issues,
341
1008
  checks: {},
1009
+ capabilityProbes: probeCapabilities,
1010
+ capabilityKeys: [],
1011
+ capabilityChecks: defaultCapabilityChecks(),
1012
+ adversarial: defaultAdversarialResult(adversarialProbes),
342
1013
  stderr: '',
343
1014
  process: defaultProcessInfo(timeoutMs),
344
1015
  staticScan: defaultStaticScan(),
345
1016
  staticFindings: [],
1017
+ toolSchema: defaultToolSchemaValidation(),
346
1018
  durationMs: 0,
347
1019
  fingerprint: createFingerprint(commandWithArgs, {
348
1020
  protocol,
349
1021
  timeoutMs,
350
1022
  cwd: options.cwd,
351
- operation,
1023
+ config: options.config,
1024
+ profile: options.profile,
1025
+ probeCapabilities,
1026
+ adversarialProbes,
1027
+ operations,
352
1028
  env: options.env
353
1029
  })
354
1030
  };
@@ -358,7 +1034,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
358
1034
  issues.push({ ...details, severity, code, message });
359
1035
  }
360
1036
 
361
- function armTimeout(code, message, timeoutPhase) {
1037
+ function armTimeout(code, message, timeoutPhase, details = {}) {
362
1038
  clearTimeout(timer);
363
1039
  result.process.phase = timeoutPhase;
364
1040
  timer = setTimeout(() => {
@@ -366,7 +1042,10 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
366
1042
  result.process.timeoutCode = code;
367
1043
  result.process.timeoutMs = timeoutMs;
368
1044
  result.process.outcome = 'timeout';
369
- addIssue('error', code, message, timeoutIssueDetails(code, timeoutMs, timeoutPhase));
1045
+ addIssue('error', code, message, {
1046
+ ...timeoutIssueDetails(code, timeoutMs, timeoutPhase),
1047
+ ...details
1048
+ });
370
1049
  finish();
371
1050
  }, timeoutMs);
372
1051
  }
@@ -406,6 +1085,346 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
406
1085
  child.stdin.write(`${JSON.stringify(message)}\n`);
407
1086
  }
408
1087
 
1088
+ function sendRaw(line) {
1089
+ if (!child?.stdin?.writable) return;
1090
+ child.stdin.write(`${line}\n`);
1091
+ }
1092
+
1093
+ function enqueueRequest(request) {
1094
+ const needsId = !request.omitId && !request.rawLine;
1095
+ requestQueue.push({
1096
+ ...request,
1097
+ id: needsId ? nextRequestId : null
1098
+ });
1099
+ if (needsId) {
1100
+ nextRequestId += 1;
1101
+ }
1102
+ }
1103
+
1104
+ function startNextRequest() {
1105
+ if (result.durationMs || currentRequest) return;
1106
+ currentRequest = requestQueue.shift() || null;
1107
+ if (!currentRequest) {
1108
+ result.process.phase = 'post-initialize';
1109
+ finishSoon();
1110
+ return;
1111
+ }
1112
+
1113
+ result.process.phase = 'operation';
1114
+ currentRequest.startedAt = Date.now();
1115
+
1116
+ if (currentRequest.kind === 'adversarial') {
1117
+ result.process.phase = 'adversarial';
1118
+ markAdversarialProbeRunning(currentRequest);
1119
+ if (currentRequest.rawLine) {
1120
+ sendRaw(currentRequest.rawLine);
1121
+ } else {
1122
+ const request = {
1123
+ jsonrpc: '2.0',
1124
+ method: currentRequest.method
1125
+ };
1126
+ if (!currentRequest.omitId) {
1127
+ request.id = currentRequest.id;
1128
+ }
1129
+ if (currentRequest.params !== undefined) {
1130
+ request.params = currentRequest.params;
1131
+ }
1132
+ send(request);
1133
+ }
1134
+ if (currentRequest.expectation === 'no-response' || currentRequest.expectation === 'error-or-no-response') {
1135
+ armAdversarialQuietTimer(currentRequest);
1136
+ } else {
1137
+ armAdversarialTimeout(currentRequest);
1138
+ }
1139
+ return;
1140
+ }
1141
+
1142
+ const request = {
1143
+ jsonrpc: '2.0',
1144
+ id: currentRequest.id,
1145
+ method: currentRequest.method
1146
+ };
1147
+ if (currentRequest.params !== undefined) {
1148
+ request.params = currentRequest.params;
1149
+ }
1150
+ send(request);
1151
+ armTimeout(
1152
+ currentRequest.timeoutCode,
1153
+ currentRequest.timeoutMessage,
1154
+ 'operation',
1155
+ currentRequest.timeoutDetails
1156
+ );
1157
+ }
1158
+
1159
+ function armAdversarialQuietTimer(request) {
1160
+ clearTimeout(timer);
1161
+ timer = setTimeout(() => {
1162
+ completeAdversarialProbe(request, 'pass');
1163
+ currentRequest = null;
1164
+ startNextRequest();
1165
+ }, request.quietMs);
1166
+ }
1167
+
1168
+ function armAdversarialTimeout(request) {
1169
+ clearTimeout(timer);
1170
+ result.process.phase = 'adversarial';
1171
+ timer = setTimeout(() => {
1172
+ result.process.timedOut = true;
1173
+ result.process.timeoutCode = 'adversarial-probe-timeout';
1174
+ result.process.timeoutMs = timeoutMs;
1175
+ result.process.outcome = 'timeout';
1176
+ failAdversarialProbe(
1177
+ request,
1178
+ 'adversarial-probe-timeout',
1179
+ `${request.probeName} adversarial probe did not receive a structured error within ${timeoutMs}ms`,
1180
+ {
1181
+ detailCode: 'adversarial-probe-timeout',
1182
+ phase: 'adversarial',
1183
+ timeoutMs
1184
+ }
1185
+ );
1186
+ finish();
1187
+ }, timeoutMs);
1188
+ }
1189
+
1190
+ function configureCapabilityChecks(capabilities) {
1191
+ result.capabilityKeys = capabilityKeys(capabilities);
1192
+ for (const definition of CAPABILITY_DEFINITIONS) {
1193
+ const check = result.capabilityChecks[definition.name];
1194
+ check.advertised = result.capabilityKeys.includes(definition.name);
1195
+ }
1196
+ }
1197
+
1198
+ function enqueuePostInitializeRequests() {
1199
+ const operationCapabilities = new Set(operations.map((operation) => capabilityNameForMethod(operation.method)).filter(Boolean));
1200
+ for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
1201
+ const operation = operations[operationIndex];
1202
+ const operationCapability = capabilityNameForMethod(operation.method);
1203
+ enqueueRequest({
1204
+ kind: 'operation',
1205
+ operationIndex,
1206
+ capability: result.capabilityChecks[operationCapability]?.advertised ? operationCapability : '',
1207
+ method: operation.method,
1208
+ params: operation.params,
1209
+ timeoutCode: 'operation-timeout',
1210
+ timeoutMessage: `no ${operation.method} response within ${timeoutMs}ms`,
1211
+ timeoutDetails: {}
1212
+ });
1213
+ }
1214
+
1215
+ if (probeCapabilities) {
1216
+ for (const definition of CAPABILITY_DEFINITIONS) {
1217
+ const check = result.capabilityChecks[definition.name];
1218
+ if (!check.advertised || operationCapabilities.has(definition.name)) continue;
1219
+ enqueueRequest({
1220
+ kind: 'capability',
1221
+ capability: definition.name,
1222
+ method: definition.method,
1223
+ timeoutCode: 'capability-list-timeout',
1224
+ timeoutMessage: `${definition.method} did not receive a response for advertised ${definition.name} capability within ${timeoutMs}ms`,
1225
+ timeoutDetails: {
1226
+ capability: definition.name,
1227
+ method: definition.method,
1228
+ detailCode: 'capability-request-timeout'
1229
+ }
1230
+ });
1231
+ }
1232
+ }
1233
+
1234
+ for (let probeIndex = 0; probeIndex < adversarialProbes.length; probeIndex += 1) {
1235
+ const probe = adversarialProbes[probeIndex];
1236
+ enqueueRequest({
1237
+ kind: 'adversarial',
1238
+ probeIndex,
1239
+ probeName: probe.name,
1240
+ method: probe.method,
1241
+ params: probe.params,
1242
+ rawLine: probe.rawLine,
1243
+ omitId: probe.omitId,
1244
+ expectation: probe.expectation,
1245
+ failureCode: probe.failureCode,
1246
+ quietMs: Math.min(timeoutMs, probe.quietMs ?? ADVERSARIAL_OBSERVATION_MS)
1247
+ });
1248
+ }
1249
+ }
1250
+
1251
+ function handleCurrentRequestResponse(message) {
1252
+ clearTimeout(timer);
1253
+ const request = currentRequest;
1254
+ if (!isJsonRpcResponse(message)) {
1255
+ addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id ${request.id} is not a ${request.method} response`);
1256
+ finish();
1257
+ return;
1258
+ }
1259
+
1260
+ if (request.kind === 'operation') {
1261
+ const operationResult = result.operations[request.operationIndex];
1262
+ operationResult.responded = true;
1263
+ if (result.operation && request.operationIndex === 0) {
1264
+ result.operation.responded = true;
1265
+ }
1266
+ if (message.error) {
1267
+ operationResult.error = message.error;
1268
+ if (result.operation && request.operationIndex === 0) {
1269
+ result.operation.error = message.error;
1270
+ }
1271
+ addIssue('warning', 'operation-error', `${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
1272
+ }
1273
+ }
1274
+
1275
+ if (!message.error && request.method === 'tools/list') {
1276
+ const validation = validateToolsListResult(message.result);
1277
+ result.toolSchema = validation.summary;
1278
+ for (const issue of validation.issues) {
1279
+ addIssue(issue.severity, issue.code, issue.message, issue.details);
1280
+ }
1281
+ }
1282
+
1283
+ if (request.capability) {
1284
+ recordCapabilityListShape(request, message.result);
1285
+ handleCapabilityResponse(request, message);
1286
+ }
1287
+
1288
+ currentRequest = null;
1289
+ startNextRequest();
1290
+ }
1291
+
1292
+ function handleCapabilityResponse(request, message) {
1293
+ const check = result.capabilityChecks[request.capability];
1294
+ check.responded = true;
1295
+ if (!message.error) return;
1296
+
1297
+ check.error = message.error;
1298
+ const unsupported = isUnsupportedMethodError(message.error);
1299
+ const code = unsupported ? 'capability-list-unsupported' : 'capability-list-error';
1300
+ const messageText = unsupported
1301
+ ? `${request.capability} capability is advertised but ${request.method} returned Method not found`
1302
+ : `${request.capability} capability is advertised but ${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`;
1303
+ addIssue('error', code, messageText, {
1304
+ capability: request.capability,
1305
+ method: request.method,
1306
+ errorCode: message.error.code ?? null
1307
+ });
1308
+ }
1309
+
1310
+ function markAdversarialProbeRunning(request) {
1311
+ const probe = result.adversarial.probes[request.probeIndex];
1312
+ if (!probe) return;
1313
+ probe.status = 'running';
1314
+ probe.started = true;
1315
+ }
1316
+
1317
+ function completeAdversarialProbe(request, status, issueCodes = [], response = {}) {
1318
+ const probe = result.adversarial.probes[request.probeIndex];
1319
+ if (!probe) return;
1320
+ probe.status = status;
1321
+ probe.responded = Boolean(response.responded);
1322
+ probe.error = response.error ?? null;
1323
+ probe.issueCodes = [...new Set(issueCodes)].sort();
1324
+ probe.durationMs = request.startedAt ? Date.now() - request.startedAt : null;
1325
+ }
1326
+
1327
+ function failAdversarialProbe(request, code, message, details = {}, response = {}) {
1328
+ completeAdversarialProbe(request, 'fail', [code], response);
1329
+ addIssue('error', code, message, {
1330
+ probe: request.probeName,
1331
+ method: request.method || '',
1332
+ ...details
1333
+ });
1334
+ }
1335
+
1336
+ function handleAdversarialFrame(message) {
1337
+ clearTimeout(timer);
1338
+ const request = currentRequest;
1339
+
1340
+ if (request.expectation === 'no-response') {
1341
+ failAdversarialProbe(
1342
+ request,
1343
+ request.failureCode,
1344
+ `${request.probeName} adversarial probe received a response to a notification`,
1345
+ {},
1346
+ { responded: true }
1347
+ );
1348
+ finish();
1349
+ return;
1350
+ }
1351
+
1352
+ if (!request.rawLine && !request.omitId && isResponseIdTypeMismatch(message, request.id)) {
1353
+ failAdversarialProbe(
1354
+ request,
1355
+ 'response-id-type-mismatch',
1356
+ `${request.probeName} adversarial response id ${JSON.stringify(message.id)} does not exactly match request id ${request.id}`,
1357
+ {},
1358
+ { responded: true }
1359
+ );
1360
+ finish();
1361
+ return;
1362
+ }
1363
+
1364
+ if (!request.rawLine && !request.omitId && isResponseIdMismatch(message, request.id)) {
1365
+ failAdversarialProbe(
1366
+ request,
1367
+ 'response-id-mismatch',
1368
+ `${request.probeName} adversarial response id ${JSON.stringify(message.id)} does not match request id ${request.id}`,
1369
+ {},
1370
+ { responded: true }
1371
+ );
1372
+ finish();
1373
+ return;
1374
+ }
1375
+
1376
+ if (!isJsonRpcResponse(message)) {
1377
+ failAdversarialProbe(
1378
+ request,
1379
+ 'adversarial-probe-invalid-stdout',
1380
+ `${request.probeName} adversarial probe received a JSON-RPC frame that was not a response`,
1381
+ {},
1382
+ { responded: true }
1383
+ );
1384
+ finish();
1385
+ return;
1386
+ }
1387
+
1388
+ if (message.error) {
1389
+ completeAdversarialProbe(request, 'pass', [], {
1390
+ responded: true,
1391
+ error: {
1392
+ code: message.error.code,
1393
+ message: message.error.message
1394
+ }
1395
+ });
1396
+ currentRequest = null;
1397
+ startNextRequest();
1398
+ return;
1399
+ }
1400
+
1401
+ failAdversarialProbe(
1402
+ request,
1403
+ request.failureCode,
1404
+ `${request.probeName} adversarial probe returned success where a structured error was expected`,
1405
+ {},
1406
+ { responded: true }
1407
+ );
1408
+ finish();
1409
+ }
1410
+
1411
+ function recordCapabilityListShape(request, responseResult) {
1412
+ if (!isObjectRecord(responseResult)) return;
1413
+ const check = result.capabilityChecks[request.capability];
1414
+ const items = responseResult[request.capability];
1415
+ if (check && Array.isArray(items)) {
1416
+ check.itemCount = items.length;
1417
+ }
1418
+ }
1419
+
1420
+ function addCapabilityMissingResponseIssue(request) {
1421
+ addIssue('error', 'capability-list-missing-response', `${request.method} did not receive a response before server exit`, {
1422
+ capability: request.capability,
1423
+ method: request.method,
1424
+ detailCode: 'capability-missing-response'
1425
+ });
1426
+ }
1427
+
409
1428
  const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
410
1429
  if (pythonBufferingIssue) {
411
1430
  addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
@@ -462,8 +1481,10 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
462
1481
  if (result.durationMs) return;
463
1482
  clearTimeout(timer);
464
1483
  const exitPhase = initialized
465
- ? result.operation && !result.operation.responded
466
- ? 'operation'
1484
+ ? currentRequest
1485
+ ? currentRequest.kind === 'adversarial'
1486
+ ? 'adversarial'
1487
+ : 'operation'
467
1488
  : 'post-initialize'
468
1489
  : 'initialize';
469
1490
  result.process.phase = exitPhase;
@@ -473,8 +1494,29 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
473
1494
  if (stdoutBuffer.trim()) {
474
1495
  addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
475
1496
  }
476
- if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
477
- addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`, exitIssueDetails('during-operation', code, signal));
1497
+ if (!endedByGuard && initialized && currentRequest?.kind === 'adversarial') {
1498
+ result.process.phase = 'adversarial';
1499
+ failAdversarialProbe(
1500
+ currentRequest,
1501
+ 'adversarial-probe-crash',
1502
+ `server exited during ${currentRequest.probeName} adversarial probe (code ${code ?? 'null'}, signal ${signal ?? 'null'})`,
1503
+ adversarialExitIssueDetails(code, signal)
1504
+ );
1505
+ finish();
1506
+ return;
1507
+ }
1508
+ if (!endedByGuard && initialized && currentRequest?.kind === 'operation') {
1509
+ const operationResult = result.operations[currentRequest.operationIndex];
1510
+ if (operationResult && !operationResult.responded) {
1511
+ addIssue('error', 'operation-missing-response', `${operationResult.method} did not receive a response before server exit`, {
1512
+ ...exitIssueDetails('during-operation', code, signal),
1513
+ operationIndex: currentRequest.operationIndex,
1514
+ method: operationResult.method
1515
+ });
1516
+ }
1517
+ }
1518
+ if (!endedByGuard && initialized && currentRequest?.capability) {
1519
+ addCapabilityMissingResponseIssue(currentRequest);
478
1520
  }
479
1521
  if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
480
1522
  addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
@@ -516,17 +1558,42 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
516
1558
  message = JSON.parse(line);
517
1559
  } catch {
518
1560
  addIssue('error', 'stdout-non-json', `stdout line ${frames.length + 1} is not JSON-RPC: ${quote(line)}`);
1561
+ if (currentRequest?.kind === 'adversarial') {
1562
+ failAdversarialProbe(
1563
+ currentRequest,
1564
+ 'adversarial-probe-invalid-stdout',
1565
+ `${currentRequest.probeName} adversarial probe received non-JSON stdout`,
1566
+ {},
1567
+ { responded: true }
1568
+ );
1569
+ finish();
1570
+ }
519
1571
  return;
520
1572
  }
521
1573
 
522
1574
  const validation = validateJsonRpc(message);
523
1575
  if (validation) {
524
1576
  addIssue('error', 'stdout-invalid-json-rpc', validation);
1577
+ if (currentRequest?.kind === 'adversarial') {
1578
+ failAdversarialProbe(
1579
+ currentRequest,
1580
+ 'adversarial-probe-invalid-stdout',
1581
+ `${currentRequest.probeName} adversarial probe received invalid JSON-RPC stdout`,
1582
+ {},
1583
+ { responded: true }
1584
+ );
1585
+ finish();
1586
+ }
525
1587
  return;
526
1588
  }
527
1589
 
528
1590
  frames.push(message);
529
1591
 
1592
+ if (initialized && currentRequest?.kind === 'adversarial') {
1593
+ handleAdversarialFrame(message);
1594
+ return;
1595
+ }
1596
+
530
1597
  if (!initialized && isResponseIdTypeMismatch(message, 1)) {
531
1598
  clearTimeout(timer);
532
1599
  addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
@@ -566,50 +1633,26 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
566
1633
  }
567
1634
 
568
1635
  initialized = true;
569
- result.process.phase = operation ? 'operation' : 'post-initialize';
1636
+ configureCapabilityChecks(message.result.capabilities);
1637
+ result.process.phase = 'post-initialize';
570
1638
  result.negotiatedProtocol = message.result?.protocolVersion || '';
571
1639
  send({ jsonrpc: '2.0', method: 'notifications/initialized' });
572
- if (operation) {
573
- const request = {
574
- jsonrpc: '2.0',
575
- id: 2,
576
- method: operation.method
577
- };
578
- if (operation.params !== undefined) {
579
- request.params = operation.params;
580
- }
581
- send(request);
582
- armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`, 'operation');
583
- } else {
584
- finishSoon();
585
- }
586
- } else if (initialized && !operation && isJsonRpcResponse(message)) {
1640
+ enqueuePostInitializeRequests();
1641
+ startNextRequest();
1642
+ } else if (initialized && currentRequest && isResponseIdTypeMismatch(message, currentRequest.id)) {
587
1643
  clearTimeout(timer);
588
- addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
1644
+ addIssue('error', 'response-id-type-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not exactly match request id ${currentRequest.id}`);
589
1645
  finish();
590
- } else if (initialized && operation && isResponseIdTypeMismatch(message, 2)) {
1646
+ } else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
591
1647
  clearTimeout(timer);
592
- addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
1648
+ addIssue('error', 'response-id-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not match request id ${currentRequest.id}`);
593
1649
  finish();
594
- } else if (initialized && operation && isResponseIdMismatch(message, 2)) {
1650
+ } else if (initialized && currentRequest && message.id === currentRequest.id) {
1651
+ handleCurrentRequestResponse(message);
1652
+ } else if (initialized && !currentRequest && isJsonRpcResponse(message)) {
595
1653
  clearTimeout(timer);
596
- addIssue('error', 'response-id-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not match request id 2`);
1654
+ addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
597
1655
  finish();
598
- } else if (initialized && operation && message.id === 2) {
599
- clearTimeout(timer);
600
- if (!isJsonRpcResponse(message)) {
601
- addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
602
- finish();
603
- return;
604
- }
605
-
606
- result.operation.responded = true;
607
- result.process.phase = 'post-initialize';
608
- if (message.error) {
609
- result.operation.error = message.error;
610
- addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
611
- }
612
- finishSoon();
613
1656
  }
614
1657
  }
615
1658
  });
@@ -655,12 +1698,41 @@ function defaultProcessInfo(timeoutMs) {
655
1698
 
656
1699
  function timeoutIssueDetails(code, timeoutMs, phase) {
657
1700
  return {
658
- detailCode: code === 'operation-timeout' ? 'request-timeout' : 'startup-timeout',
1701
+ detailCode: code === 'operation-timeout'
1702
+ ? 'request-timeout'
1703
+ : code === 'capability-list-timeout'
1704
+ ? 'capability-request-timeout'
1705
+ : 'startup-timeout',
659
1706
  phase,
660
1707
  timeoutMs
661
1708
  };
662
1709
  }
663
1710
 
1711
+ function defaultCapabilityChecks() {
1712
+ return Object.fromEntries(CAPABILITY_DEFINITIONS.map((definition) => [
1713
+ definition.name,
1714
+ {
1715
+ advertised: false,
1716
+ method: definition.method,
1717
+ responded: false,
1718
+ itemCount: null,
1719
+ error: null
1720
+ }
1721
+ ]));
1722
+ }
1723
+
1724
+ function capabilityNameForMethod(method) {
1725
+ return CAPABILITY_DEFINITIONS.find((definition) => definition.method === method)?.name ?? '';
1726
+ }
1727
+
1728
+ function capabilityKeys(capabilities) {
1729
+ return isObjectRecord(capabilities) ? Object.keys(capabilities).sort() : [];
1730
+ }
1731
+
1732
+ function isUnsupportedMethodError(error) {
1733
+ return error?.code === -32601 || /method not found/i.test(error?.message || '');
1734
+ }
1735
+
664
1736
  function exitIssueDetails(position, code, signal) {
665
1737
  return {
666
1738
  detailCode: exitDetailCode(position, code, signal),
@@ -674,6 +1746,15 @@ function exitIssueDetails(position, code, signal) {
674
1746
  };
675
1747
  }
676
1748
 
1749
+ function adversarialExitIssueDetails(code, signal) {
1750
+ return {
1751
+ detailCode: exitDetailCode('during-adversarial', code, signal),
1752
+ phase: 'adversarial',
1753
+ exitCode: code,
1754
+ signal
1755
+ };
1756
+ }
1757
+
677
1758
  function exitDetailCode(position, code, signal) {
678
1759
  if (signal) return `signal-exit-${position}`;
679
1760
  if (code === 0) return `clean-exit-${position}`;
@@ -786,35 +1867,286 @@ function validateInitializeResult(result) {
786
1867
  });
787
1868
  }
788
1869
 
789
- if (!Object.hasOwn(result, 'capabilities')) {
790
- issues.push({
791
- severity: 'error',
792
- code: 'initialize-missing-capabilities',
793
- message: 'initialize result is missing capabilities'
794
- });
795
- } else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
796
- issues.push({
797
- severity: 'error',
798
- code: 'initialize-invalid-capabilities',
799
- message: 'initialize result capabilities must be an object'
800
- });
1870
+ if (!Object.hasOwn(result, 'capabilities')) {
1871
+ issues.push({
1872
+ severity: 'error',
1873
+ code: 'initialize-missing-capabilities',
1874
+ message: 'initialize result is missing capabilities'
1875
+ });
1876
+ } else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
1877
+ issues.push({
1878
+ severity: 'error',
1879
+ code: 'initialize-invalid-capabilities',
1880
+ message: 'initialize result capabilities must be an object'
1881
+ });
1882
+ }
1883
+
1884
+ if (!Object.hasOwn(result, 'serverInfo')) {
1885
+ issues.push({
1886
+ severity: 'warning',
1887
+ code: 'initialize-missing-server-info',
1888
+ message: 'initialize result is missing serverInfo'
1889
+ });
1890
+ } else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
1891
+ issues.push({
1892
+ severity: 'warning',
1893
+ code: 'initialize-invalid-server-info',
1894
+ message: 'initialize result serverInfo should be an object'
1895
+ });
1896
+ }
1897
+
1898
+ return issues;
1899
+ }
1900
+
1901
+ function validateToolsListResult(result) {
1902
+ const summary = {
1903
+ ...defaultToolSchemaValidation(),
1904
+ checked: true
1905
+ };
1906
+ const issues = [];
1907
+
1908
+ if (!isObjectRecord(result) || !Array.isArray(result.tools)) {
1909
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', 'tools/list result must be an object with a tools array');
1910
+ summary.errorCount = 1;
1911
+ return { summary, issues };
1912
+ }
1913
+
1914
+ summary.toolCount = result.tools.length;
1915
+ const seenNames = new Map();
1916
+
1917
+ for (let index = 0; index < result.tools.length; index += 1) {
1918
+ const tool = result.tools[index];
1919
+ let toolValid = true;
1920
+
1921
+ if (!isObjectRecord(tool)) {
1922
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', `tools[${index}] must be an object`, { toolIndex: index });
1923
+ continue;
1924
+ }
1925
+
1926
+ const name = validateToolName(tool, index, issues);
1927
+ toolValid = Boolean(name);
1928
+ if (name) {
1929
+ summary.toolNames.push(name);
1930
+ if (seenNames.has(name)) {
1931
+ const firstToolIndex = seenNames.get(name);
1932
+ toolValid = false;
1933
+ summary.duplicateNames.push(name);
1934
+ addToolSchemaIssue(
1935
+ issues,
1936
+ 'error',
1937
+ 'tool-name-duplicate',
1938
+ `tools/list returned duplicate tool name ${JSON.stringify(name)}`,
1939
+ { toolIndex: index, firstToolIndex, toolName: name }
1940
+ );
1941
+ } else {
1942
+ seenNames.set(name, index);
1943
+ }
1944
+ }
1945
+
1946
+ if (typeof tool.description !== 'string' || !tool.description.trim()) {
1947
+ addToolSchemaIssue(
1948
+ issues,
1949
+ 'warning',
1950
+ 'tool-description-missing',
1951
+ name
1952
+ ? `tool ${JSON.stringify(name)} is missing a non-empty description`
1953
+ : `tools[${index}] is missing a non-empty description`,
1954
+ { toolIndex: index, toolName: name || '' }
1955
+ );
1956
+ }
1957
+
1958
+ if (!Object.hasOwn(tool, 'inputSchema')) {
1959
+ addToolSchemaIssue(
1960
+ issues,
1961
+ 'error',
1962
+ 'tool-input-schema-invalid',
1963
+ name
1964
+ ? `tool ${JSON.stringify(name)} is missing inputSchema`
1965
+ : `tools[${index}] is missing inputSchema`,
1966
+ { toolIndex: index, toolName: name || '', schemaPath: 'inputSchema' }
1967
+ );
1968
+ toolValid = false;
1969
+ } else {
1970
+ toolValid = validateInputSchema(tool.inputSchema, {
1971
+ toolIndex: index,
1972
+ toolName: name || '',
1973
+ schemaPath: 'inputSchema'
1974
+ }, issues) && toolValid;
1975
+ }
1976
+
1977
+ if (toolValid) {
1978
+ summary.validToolCount += 1;
1979
+ }
1980
+ }
1981
+
1982
+ summary.duplicateNames = [...new Set(summary.duplicateNames)].sort();
1983
+ summary.toolNames = [...new Set(summary.toolNames)].sort();
1984
+ summary.errorCount = issues.filter((issue) => issue.severity === 'error').length;
1985
+ summary.warningCount = issues.filter((issue) => issue.severity === 'warning').length;
1986
+ return { summary, issues };
1987
+ }
1988
+
1989
+ function validateToolName(tool, index, issues) {
1990
+ if (typeof tool.name !== 'string' || !tool.name.trim() || tool.name !== tool.name.trim()) {
1991
+ addToolSchemaIssue(issues, 'error', 'tool-name-invalid', `tools[${index}] name must be a stable non-empty string`, {
1992
+ toolIndex: index,
1993
+ toolName: typeof tool.name === 'string' ? tool.name : ''
1994
+ });
1995
+ return '';
1996
+ }
1997
+
1998
+ return tool.name;
1999
+ }
2000
+
2001
+ function validateInputSchema(schema, context, issues) {
2002
+ return validateSchemaValue(schema, context, issues);
2003
+ }
2004
+
2005
+ function validateSchemaValue(schema, context, issues) {
2006
+ if (typeof schema === 'boolean') {
2007
+ return true;
2008
+ }
2009
+
2010
+ return validateSchemaObject(schema, context, issues);
2011
+ }
2012
+
2013
+ function validateSchemaObject(schema, context, issues) {
2014
+ if (!isObjectRecord(schema)) {
2015
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a JSON Schema object or boolean schema`, context);
2016
+ return false;
2017
+ }
2018
+
2019
+ let valid = true;
2020
+
2021
+ if (Object.hasOwn(schema, 'type') && !isValidJsonSchemaType(schema.type)) {
2022
+ valid = false;
2023
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.type must be a string or array of strings`, context);
2024
+ }
2025
+
2026
+ if (Object.hasOwn(schema, 'properties')) {
2027
+ if (!isObjectRecord(schema.properties)) {
2028
+ valid = false;
2029
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.properties must be an object`, context);
2030
+ } else {
2031
+ for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
2032
+ valid = validateSchemaValue(propertySchema, {
2033
+ ...context,
2034
+ schemaPath: `${context.schemaPath}.properties.${propertyName}`
2035
+ }, issues) && valid;
2036
+ }
2037
+ }
2038
+ }
2039
+
2040
+ if (Object.hasOwn(schema, 'required')) {
2041
+ valid = validateRequired(schema, context, issues) && valid;
2042
+ }
2043
+
2044
+ if (Object.hasOwn(schema, 'items')) {
2045
+ valid = validateSchemaValueOrArray(schema.items, {
2046
+ ...context,
2047
+ schemaPath: `${context.schemaPath}.items`
2048
+ }, issues) && valid;
2049
+ }
2050
+
2051
+ if (Object.hasOwn(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') {
2052
+ valid = validateSchemaObject(schema.additionalProperties, {
2053
+ ...context,
2054
+ schemaPath: `${context.schemaPath}.additionalProperties`
2055
+ }, issues) && valid;
2056
+ }
2057
+
2058
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
2059
+ if (Object.hasOwn(schema, keyword)) {
2060
+ valid = validateSchemaArray(schema[keyword], {
2061
+ ...context,
2062
+ schemaPath: `${context.schemaPath}.${keyword}`
2063
+ }, issues) && valid;
2064
+ }
2065
+ }
2066
+
2067
+ if (Object.hasOwn(schema, 'enum') && !Array.isArray(schema.enum)) {
2068
+ valid = false;
2069
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.enum must be an array`, context);
2070
+ }
2071
+
2072
+ return valid;
2073
+ }
2074
+
2075
+ function validateSchemaValueOrArray(value, context, issues) {
2076
+ if (Array.isArray(value)) {
2077
+ return validateSchemaArray(value, context, issues);
2078
+ }
2079
+
2080
+ return validateSchemaValue(value, context, issues);
2081
+ }
2082
+
2083
+ function validateSchemaArray(value, context, issues) {
2084
+ if (!Array.isArray(value) || !value.length) {
2085
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a non-empty array of schemas`, context);
2086
+ return false;
2087
+ }
2088
+
2089
+ let valid = true;
2090
+ for (let index = 0; index < value.length; index += 1) {
2091
+ valid = validateSchemaValue(value[index], {
2092
+ ...context,
2093
+ schemaPath: `${context.schemaPath}[${index}]`
2094
+ }, issues) && valid;
2095
+ }
2096
+ return valid;
2097
+ }
2098
+
2099
+ function validateRequired(schema, context, issues) {
2100
+ if (!Array.isArray(schema.required) || schema.required.some((entry) => typeof entry !== 'string' || !entry)) {
2101
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must be an array of non-empty strings`, context);
2102
+ return false;
2103
+ }
2104
+
2105
+ let valid = true;
2106
+ const seen = new Set();
2107
+ for (const propertyName of schema.required) {
2108
+ if (seen.has(propertyName)) {
2109
+ valid = false;
2110
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must not contain duplicate entries`, {
2111
+ ...context,
2112
+ propertyName
2113
+ });
2114
+ }
2115
+ seen.add(propertyName);
2116
+
2117
+ if (!isObjectRecord(schema.properties) || !Object.hasOwn(schema.properties, propertyName)) {
2118
+ valid = false;
2119
+ addToolSchemaIssue(
2120
+ issues,
2121
+ 'error',
2122
+ 'tool-input-schema-required-missing',
2123
+ `${context.schemaPath}.required references missing property ${JSON.stringify(propertyName)}`,
2124
+ { ...context, propertyName }
2125
+ );
2126
+ }
801
2127
  }
802
2128
 
803
- if (!Object.hasOwn(result, 'serverInfo')) {
804
- issues.push({
805
- severity: 'warning',
806
- code: 'initialize-missing-server-info',
807
- message: 'initialize result is missing serverInfo'
808
- });
809
- } else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
810
- issues.push({
811
- severity: 'warning',
812
- code: 'initialize-invalid-server-info',
813
- message: 'initialize result serverInfo should be an object'
814
- });
2129
+ return valid;
2130
+ }
2131
+
2132
+ function isValidJsonSchemaType(value) {
2133
+ if (typeof value === 'string') {
2134
+ return JSON_SCHEMA_TYPES.has(value);
815
2135
  }
2136
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && JSON_SCHEMA_TYPES.has(entry));
2137
+ }
816
2138
 
817
- return issues;
2139
+ function addToolSchemaIssue(issues, severity, code, message, details = {}) {
2140
+ issues.push({
2141
+ severity,
2142
+ code,
2143
+ message,
2144
+ details
2145
+ });
2146
+ }
2147
+
2148
+ function isObjectRecord(value) {
2149
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
818
2150
  }
819
2151
 
820
2152
  export function classifyIssueCode(code) {
@@ -823,7 +2155,8 @@ export function classifyIssueCode(code) {
823
2155
 
824
2156
  export function createFingerprint(commandWithArgs, options = {}) {
825
2157
  const cwd = path.resolve(options.cwd ?? process.cwd());
826
- const operation = options.operation || null;
2158
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
2159
+ const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
827
2160
 
828
2161
  return {
829
2162
  guard: {
@@ -841,14 +2174,32 @@ export function createFingerprint(commandWithArgs, options = {}) {
841
2174
  exists: fs.existsSync(cwd)
842
2175
  },
843
2176
  protocol: options.protocol ?? DEFAULT_PROTOCOL,
2177
+ config: options.config ?? defaultConfigMetadata(),
2178
+ profile: options.profile ?? DEFAULT_PROFILE,
844
2179
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
845
2180
  repeat: options.repeat ?? 1,
846
- operation: operation
2181
+ capabilityProbes: options.probeCapabilities ?? true,
2182
+ adversarialProbes: adversarialProbes.map((probe) => ({
2183
+ name: probe.name,
2184
+ source: probe.source,
2185
+ method: probe.method || '',
2186
+ safeToolCallName: probe.safeToolCallName || '',
2187
+ expectation: probe.expectation
2188
+ })),
2189
+ operation: operations.length === 1
847
2190
  ? {
848
- method: operation.method,
849
- hasParams: operation.params !== undefined
2191
+ method: operations[0].method,
2192
+ hasParams: operations[0].params !== undefined,
2193
+ source: operations[0].source,
2194
+ safeToolCallName: operations[0].safeToolCallName
850
2195
  }
851
2196
  : null,
2197
+ operations: operations.map((operation) => ({
2198
+ method: operation.method,
2199
+ hasParams: operation.params !== undefined,
2200
+ source: operation.source,
2201
+ safeToolCallName: operation.safeToolCallName
2202
+ })),
852
2203
  system: {
853
2204
  platform: process.platform,
854
2205
  arch: process.arch,
@@ -1171,9 +2522,22 @@ function isNpxCommand(base) {
1171
2522
 
1172
2523
  function finalizeResult(result) {
1173
2524
  result.schemaVersion = JSON_SCHEMA_VERSION;
2525
+ result.config ??= defaultConfigMetadata();
2526
+ result.profile ??= DEFAULT_PROFILE;
2527
+ result.capabilityProbes ??= true;
2528
+ result.adversarial ??= defaultAdversarialResult();
2529
+ result.operations ??= result.operation ? [{
2530
+ index: 0,
2531
+ source: result.operation.source ?? 'request',
2532
+ method: result.operation.method,
2533
+ safeToolCallName: result.operation.safeToolCallName ?? '',
2534
+ responded: Boolean(result.operation.responded),
2535
+ error: result.operation.error ?? null
2536
+ }] : [];
1174
2537
  result.staticScan ??= defaultStaticScan();
1175
2538
  result.staticFindings ??= [];
1176
2539
  result.issues = normalizeIssues(result.issues ?? []);
2540
+ finalizeAdversarialResult(result);
1177
2541
  result.ok = !result.issues.some((issue) => issue.severity === 'error');
1178
2542
  result.checks = buildChecks(result);
1179
2543
  result.issueClasses = buildIssueClasses(result.issues);
@@ -1181,6 +2545,17 @@ function finalizeResult(result) {
1181
2545
  return result;
1182
2546
  }
1183
2547
 
2548
+ function finalizeAdversarialResult(result) {
2549
+ if (!result.adversarial?.enabled) return;
2550
+ for (const probe of result.adversarial.probes) {
2551
+ if (probe.status === 'pending' || probe.status === 'running') {
2552
+ probe.status = 'skipped';
2553
+ probe.durationMs ??= null;
2554
+ }
2555
+ probe.issueCodes = [...new Set(probe.issueCodes ?? [])].sort();
2556
+ }
2557
+ }
2558
+
1184
2559
  function finalizeFingerprint(result) {
1185
2560
  if (!result.fingerprint) return;
1186
2561
  result.fingerprint.timings ??= {};
@@ -1216,6 +2591,15 @@ function buildChecks(result) {
1216
2591
  operation: repeated
1217
2592
  ? aggregateRunCheck(result, 'operation')
1218
2593
  : buildOperationCheck(result, issues),
2594
+ capabilities: repeated
2595
+ ? aggregateCapabilityChecks(result)
2596
+ : buildCapabilityChecks(result, issues),
2597
+ toolSchema: repeated
2598
+ ? aggregateRunCheck(result, 'toolSchema')
2599
+ : buildToolSchemaCheck(result, issues),
2600
+ adversarial: repeated
2601
+ ? aggregateRunCheck(result, 'adversarial')
2602
+ : buildAdversarialCheck(result, issues),
1219
2603
  process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
1220
2604
  pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
1221
2605
  staticScan: buildStaticScanCheck(result, issues),
@@ -1241,7 +2625,13 @@ function buildInitializeCheck(result, issues) {
1241
2625
  }
1242
2626
 
1243
2627
  function buildOperationCheck(result, issues) {
1244
- if (!result.operation) {
2628
+ const operations = result.operations?.length
2629
+ ? result.operations
2630
+ : result.operation
2631
+ ? [result.operation]
2632
+ : [];
2633
+
2634
+ if (!operations.length) {
1245
2635
  return makeCheck('skipped', []);
1246
2636
  }
1247
2637
 
@@ -1251,14 +2641,130 @@ function buildOperationCheck(result, issues) {
1251
2641
 
1252
2642
  const matched = issues.filter((issue) => (
1253
2643
  OPERATION_ISSUE_CODES.has(issue.code)
1254
- || (!result.operation.responded && STDOUT_ISSUE_CODES.has(issue.code))
1255
- || (!result.operation.responded && JSON_RPC_ISSUE_CODES.has(issue.code))
2644
+ || (operations.some((operation) => !operation.responded) && STDOUT_ISSUE_CODES.has(issue.code))
2645
+ || (operations.some((operation) => !operation.responded) && JSON_RPC_ISSUE_CODES.has(issue.code))
2646
+ ));
2647
+ if (matched.length) {
2648
+ return makeCheck(statusFromIssues(matched), matched);
2649
+ }
2650
+
2651
+ return makeCheck(operations.every((operation) => operation.responded) ? 'pass' : 'fail', []);
2652
+ }
2653
+
2654
+ function buildToolSchemaCheck(result, issues) {
2655
+ if (!result.toolSchema?.checked) {
2656
+ return makeCheck('skipped', []);
2657
+ }
2658
+
2659
+ const matched = issues.filter((issue) => TOOL_SCHEMA_ISSUE_CODES.has(issue.code));
2660
+ if (matched.length) {
2661
+ return makeCheck(statusFromIssues(matched), matched);
2662
+ }
2663
+
2664
+ return makeCheck('pass', []);
2665
+ }
2666
+
2667
+ function buildAdversarialCheck(result, issues) {
2668
+ if (!result.adversarial?.enabled) {
2669
+ return makeCheck('skipped', []);
2670
+ }
2671
+
2672
+ const probeNames = new Set(result.adversarial.probes.map((probe) => probe.name));
2673
+ const matched = issues.filter((issue) => (
2674
+ ADVERSARIAL_ISSUE_CODES.has(issue.code)
2675
+ || (probeNames.has(issue.probe) && issue.class === ISSUE_CLASSES.MCP_PROTOCOL)
1256
2676
  ));
1257
2677
  if (matched.length) {
1258
2678
  return makeCheck(statusFromIssues(matched), matched);
1259
2679
  }
1260
2680
 
1261
- return makeCheck(result.operation.responded ? 'pass' : 'fail', []);
2681
+ const active = result.adversarial.probes.filter((probe) => probe.status !== 'skipped');
2682
+ if (!result.initialized || !active.length) {
2683
+ return makeCheck('skipped', []);
2684
+ }
2685
+
2686
+ return makeCheck(active.every((probe) => probe.status === 'pass') ? 'pass' : 'fail', []);
2687
+ }
2688
+
2689
+ function buildCapabilityChecks(result, issues) {
2690
+ const checks = {};
2691
+ for (const definition of CAPABILITY_DEFINITIONS) {
2692
+ const state = result.capabilityChecks?.[definition.name] ?? defaultCapabilityChecks()[definition.name];
2693
+ const matched = issues.filter((issue) => (
2694
+ CAPABILITY_ISSUE_CODES.has(issue.code)
2695
+ && issue.capability === definition.name
2696
+ ));
2697
+ if (!result.initialized || !state.advertised || result.capabilityProbes === false) {
2698
+ checks[definition.name] = makeCapabilityCheck('skipped', matched, state);
2699
+ } else if (matched.length) {
2700
+ checks[definition.name] = makeCapabilityCheck(statusFromIssues(matched), matched, state);
2701
+ } else {
2702
+ checks[definition.name] = makeCapabilityCheck(state.responded ? 'pass' : 'fail', matched, state);
2703
+ }
2704
+ }
2705
+
2706
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2707
+ const status = active.length
2708
+ ? active.some((check) => check.status === 'fail')
2709
+ ? 'fail'
2710
+ : active.some((check) => check.status === 'warning')
2711
+ ? 'warning'
2712
+ : 'pass'
2713
+ : 'skipped';
2714
+ return {
2715
+ status,
2716
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2717
+ ...checks
2718
+ };
2719
+ }
2720
+
2721
+ function aggregateCapabilityChecks(result) {
2722
+ const checks = {};
2723
+ for (const definition of CAPABILITY_DEFINITIONS) {
2724
+ const runChecks = result.runs
2725
+ .map((run) => run.checks?.capabilities?.[definition.name])
2726
+ .filter(Boolean);
2727
+ const activeRunChecks = runChecks.filter((check) => check.status !== 'skipped');
2728
+ const state = aggregateCapabilityState(definition, runChecks);
2729
+ if (!activeRunChecks.length) {
2730
+ checks[definition.name] = makeAggregatedCapabilityCheck('skipped', [], state);
2731
+ } else {
2732
+ const status = activeRunChecks.some((check) => check.status === 'fail')
2733
+ ? 'fail'
2734
+ : activeRunChecks.some((check) => check.status === 'warning')
2735
+ ? 'warning'
2736
+ : 'pass';
2737
+ checks[definition.name] = makeAggregatedCapabilityCheck(status, activeRunChecks, state);
2738
+ }
2739
+ }
2740
+
2741
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2742
+ const status = active.length
2743
+ ? active.some((check) => check.status === 'fail')
2744
+ ? 'fail'
2745
+ : active.some((check) => check.status === 'warning')
2746
+ ? 'warning'
2747
+ : 'pass'
2748
+ : 'skipped';
2749
+ return {
2750
+ status,
2751
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2752
+ ...checks
2753
+ };
2754
+ }
2755
+
2756
+ function aggregateCapabilityState(definition, runChecks) {
2757
+ const itemCounts = [...new Set(
2758
+ runChecks
2759
+ .map((check) => check.itemCount)
2760
+ .filter((itemCount) => Number.isInteger(itemCount))
2761
+ )];
2762
+ return {
2763
+ advertised: runChecks.some((check) => check.advertised),
2764
+ method: definition.method,
2765
+ responded: runChecks.some((check) => check.responded),
2766
+ itemCount: itemCounts.length === 1 ? itemCounts[0] : null
2767
+ };
1262
2768
  }
1263
2769
 
1264
2770
  function buildStaticScanCheck(result, issues) {
@@ -1284,8 +2790,16 @@ function buildRepeatCheck(result) {
1284
2790
  }
1285
2791
 
1286
2792
  const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
2793
+ const repeatIssues = (result.issues ?? []).filter((issue) => (
2794
+ issue.run || REPEAT_DRIFT_ISSUE_CODES.has(issue.code)
2795
+ ));
2796
+ const status = failedRuns.length
2797
+ ? 'fail'
2798
+ : repeatIssues.some((issue) => issue.severity === 'warning')
2799
+ ? 'warning'
2800
+ : 'pass';
1287
2801
  return {
1288
- ...makeCheck(failedRuns.length ? 'fail' : 'pass', result.issues ?? []),
2802
+ ...makeCheck(status, repeatIssues),
1289
2803
  runs: result.runs.length,
1290
2804
  passedRuns: result.runs.length - failedRuns.length,
1291
2805
  failedRuns
@@ -1327,6 +2841,102 @@ function makeCheck(status, issues) {
1327
2841
  };
1328
2842
  }
1329
2843
 
2844
+ function makeCapabilityCheck(status, issues, state) {
2845
+ return {
2846
+ ...makeCheck(status, issues),
2847
+ advertised: Boolean(state.advertised),
2848
+ method: state.method,
2849
+ responded: Boolean(state.responded),
2850
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2851
+ };
2852
+ }
2853
+
2854
+ function makeAggregatedCapabilityCheck(status, checks, state) {
2855
+ return {
2856
+ status,
2857
+ issueCodes: [...new Set(checks.flatMap((check) => check.issueCodes ?? []))].sort(),
2858
+ advertised: Boolean(state.advertised),
2859
+ method: state.method,
2860
+ responded: Boolean(state.responded),
2861
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2862
+ };
2863
+ }
2864
+
2865
+ function defaultConfigMetadata() {
2866
+ return {
2867
+ enabled: false,
2868
+ path: '',
2869
+ resolvedPath: '',
2870
+ checks: {
2871
+ command: false,
2872
+ cwd: false,
2873
+ envNames: [],
2874
+ requests: [],
2875
+ safeToolCalls: [],
2876
+ adversarialProbes: [],
2877
+ adversarialToolCalls: []
2878
+ }
2879
+ };
2880
+ }
2881
+
2882
+ function defaultAdversarialResult(probes = []) {
2883
+ return {
2884
+ enabled: probes.length > 0,
2885
+ probes: probes.map((probe, index) => ({
2886
+ index,
2887
+ name: probe.name,
2888
+ source: probe.source,
2889
+ method: probe.method || '',
2890
+ safeToolCallName: probe.safeToolCallName || '',
2891
+ expectation: probe.expectation,
2892
+ description: probe.description,
2893
+ risk: probe.risk,
2894
+ status: 'pending',
2895
+ started: false,
2896
+ responded: false,
2897
+ error: null,
2898
+ issueCodes: [],
2899
+ durationMs: null
2900
+ }))
2901
+ };
2902
+ }
2903
+
2904
+ function aggregateRunAdversarial(runs, probes = []) {
2905
+ if (!probes.length) {
2906
+ return defaultAdversarialResult();
2907
+ }
2908
+
2909
+ return {
2910
+ enabled: true,
2911
+ probes: probes.map((probe, index) => {
2912
+ const runProbes = runs
2913
+ .map((run) => run.adversarial?.probes?.[index])
2914
+ .filter(Boolean);
2915
+ const active = runProbes.filter((runProbe) => runProbe.status !== 'skipped');
2916
+ const status = active.length
2917
+ ? active.some((runProbe) => runProbe.status === 'fail')
2918
+ ? 'fail'
2919
+ : active.every((runProbe) => runProbe.status === 'pass')
2920
+ ? 'pass'
2921
+ : 'warning'
2922
+ : 'skipped';
2923
+ return {
2924
+ index,
2925
+ name: probe.name,
2926
+ source: probe.source,
2927
+ method: probe.method || '',
2928
+ safeToolCallName: probe.safeToolCallName || '',
2929
+ expectation: probe.expectation,
2930
+ description: probe.description,
2931
+ risk: probe.risk,
2932
+ status,
2933
+ runs: runProbes.length,
2934
+ issueCodes: [...new Set(runProbes.flatMap((runProbe) => runProbe.issueCodes ?? []))].sort()
2935
+ };
2936
+ })
2937
+ };
2938
+ }
2939
+
1330
2940
  function defaultStaticScan() {
1331
2941
  return {
1332
2942
  enabled: false,
@@ -1335,6 +2945,253 @@ function defaultStaticScan() {
1335
2945
  };
1336
2946
  }
1337
2947
 
2948
+ function defaultToolSchemaValidation() {
2949
+ return {
2950
+ checked: false,
2951
+ toolCount: 0,
2952
+ toolNames: [],
2953
+ validToolCount: 0,
2954
+ warningCount: 0,
2955
+ errorCount: 0,
2956
+ duplicateNames: []
2957
+ };
2958
+ }
2959
+
2960
+ function aggregateRunToolSchemaValidation(runs) {
2961
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
2962
+ if (!checkedRuns.length) {
2963
+ return defaultToolSchemaValidation();
2964
+ }
2965
+
2966
+ return {
2967
+ checked: true,
2968
+ runs: checkedRuns.length,
2969
+ toolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.toolCount, 0),
2970
+ validToolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.validToolCount, 0),
2971
+ warningCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.warningCount, 0),
2972
+ errorCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.errorCount, 0),
2973
+ toolNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.toolNames ?? []))].sort(),
2974
+ duplicateNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.duplicateNames))].sort()
2975
+ };
2976
+ }
2977
+
2978
+ function defaultRepeatDrift() {
2979
+ return {
2980
+ checked: false,
2981
+ status: 'skipped',
2982
+ issueCodes: [],
2983
+ baselineRun: null,
2984
+ comparedRuns: [],
2985
+ negotiatedProtocol: driftSection(),
2986
+ capabilities: driftSection(),
2987
+ tools: driftSection(),
2988
+ lists: {
2989
+ resources: driftSection(),
2990
+ prompts: driftSection()
2991
+ }
2992
+ };
2993
+ }
2994
+
2995
+ function driftSection() {
2996
+ return {
2997
+ status: 'skipped',
2998
+ issueCodes: [],
2999
+ baseline: null,
3000
+ values: [],
3001
+ changedRuns: []
3002
+ };
3003
+ }
3004
+
3005
+ function buildRepeatDrift(runs) {
3006
+ const drift = defaultRepeatDrift();
3007
+ if (!Array.isArray(runs) || runs.length < 2) {
3008
+ return drift;
3009
+ }
3010
+
3011
+ drift.checked = true;
3012
+ const comparableRuns = runs.filter((run) => run.initialized);
3013
+ drift.comparedRuns = comparableRuns.map((run) => run.run);
3014
+ if (comparableRuns.length < 2) {
3015
+ return drift;
3016
+ }
3017
+
3018
+ drift.baselineRun = comparableRuns[0].run;
3019
+ drift.negotiatedProtocol = compareScalarDrift(
3020
+ comparableRuns,
3021
+ (run) => run.negotiatedProtocol || '',
3022
+ 'repeat-protocol-drift'
3023
+ );
3024
+ drift.capabilities = compareSetDrift(
3025
+ comparableRuns,
3026
+ (run) => run.capabilityKeys ?? advertisedCapabilityKeys(run.capabilityChecks),
3027
+ 'repeat-capability-drift'
3028
+ );
3029
+ drift.tools = compareToolDrift(comparableRuns);
3030
+ drift.lists.resources = compareListShapeDrift(comparableRuns, 'resources');
3031
+ drift.lists.prompts = compareListShapeDrift(comparableRuns, 'prompts');
3032
+
3033
+ const sections = [
3034
+ drift.negotiatedProtocol,
3035
+ drift.capabilities,
3036
+ drift.tools,
3037
+ drift.lists.resources,
3038
+ drift.lists.prompts
3039
+ ];
3040
+ const active = sections.filter((section) => section.status !== 'skipped');
3041
+ drift.status = active.length
3042
+ ? active.some((section) => section.status === 'warning')
3043
+ ? 'warning'
3044
+ : 'pass'
3045
+ : 'skipped';
3046
+ drift.issueCodes = [...new Set(active.flatMap((section) => section.issueCodes))].sort();
3047
+ return drift;
3048
+ }
3049
+
3050
+ function compareScalarDrift(runs, valueForRun, issueCode) {
3051
+ const section = driftSection();
3052
+ section.values = runs.map((run) => ({
3053
+ run: run.run,
3054
+ value: valueForRun(run)
3055
+ }));
3056
+ section.baseline = section.values[0].value;
3057
+ section.changedRuns = section.values
3058
+ .slice(1)
3059
+ .filter((entry) => entry.value !== section.baseline)
3060
+ .map((entry) => ({
3061
+ run: entry.run,
3062
+ expected: section.baseline,
3063
+ actual: entry.value
3064
+ }));
3065
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
3066
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
3067
+ return section;
3068
+ }
3069
+
3070
+ function compareSetDrift(runs, valuesForRun, issueCode) {
3071
+ const section = driftSection();
3072
+ section.values = runs.map((run) => ({
3073
+ run: run.run,
3074
+ values: sortedUnique(valuesForRun(run))
3075
+ }));
3076
+ section.baseline = section.values[0].values;
3077
+ section.changedRuns = section.values
3078
+ .slice(1)
3079
+ .map((entry) => ({
3080
+ run: entry.run,
3081
+ added: arrayDifference(entry.values, section.baseline),
3082
+ removed: arrayDifference(section.baseline, entry.values)
3083
+ }))
3084
+ .filter((entry) => entry.added.length || entry.removed.length);
3085
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
3086
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
3087
+ return section;
3088
+ }
3089
+
3090
+ function compareToolDrift(runs) {
3091
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
3092
+ if (checkedRuns.length < 2) {
3093
+ return driftSection();
3094
+ }
3095
+
3096
+ const section = compareSetDrift(
3097
+ checkedRuns,
3098
+ (run) => run.toolSchema.toolNames ?? [],
3099
+ 'repeat-tool-drift'
3100
+ );
3101
+ section.values = checkedRuns.map((run) => ({
3102
+ run: run.run,
3103
+ count: run.toolSchema.toolCount,
3104
+ values: sortedUnique(run.toolSchema.toolNames ?? [])
3105
+ }));
3106
+ section.baselineCount = section.values[0].count;
3107
+ const countDrift = section.values
3108
+ .slice(1)
3109
+ .filter((entry) => entry.count !== section.baselineCount)
3110
+ .map((entry) => ({
3111
+ run: entry.run,
3112
+ expected: section.baselineCount,
3113
+ actual: entry.count
3114
+ }));
3115
+ if (countDrift.length) {
3116
+ section.countChangedRuns = countDrift;
3117
+ section.status = 'warning';
3118
+ section.issueCodes = ['repeat-tool-drift'];
3119
+ }
3120
+ return section;
3121
+ }
3122
+
3123
+ function compareListShapeDrift(runs, capability) {
3124
+ const checkedRuns = runs.filter((run) => Number.isInteger(run.capabilityChecks?.[capability]?.itemCount));
3125
+ if (checkedRuns.length < 2) {
3126
+ return driftSection();
3127
+ }
3128
+
3129
+ const section = compareScalarDrift(
3130
+ checkedRuns,
3131
+ (run) => run.capabilityChecks[capability].itemCount,
3132
+ 'repeat-list-shape-drift'
3133
+ );
3134
+ section.capability = capability;
3135
+ return section;
3136
+ }
3137
+
3138
+ function repeatDriftIssues(drift) {
3139
+ if (!drift?.checked || drift.status !== 'warning') {
3140
+ return [];
3141
+ }
3142
+
3143
+ const issues = [];
3144
+ if (drift.negotiatedProtocol.status === 'warning') {
3145
+ issues.push(repeatDriftIssue('repeat-protocol-drift', 'negotiated protocol changed across repeat runs', {
3146
+ drift: drift.negotiatedProtocol
3147
+ }));
3148
+ }
3149
+ if (drift.capabilities.status === 'warning') {
3150
+ issues.push(repeatDriftIssue('repeat-capability-drift', 'advertised capability keys changed across repeat runs', {
3151
+ drift: drift.capabilities
3152
+ }));
3153
+ }
3154
+ if (drift.tools.status === 'warning') {
3155
+ issues.push(repeatDriftIssue('repeat-tool-drift', 'tools/list tool names or counts changed across repeat runs', {
3156
+ drift: drift.tools
3157
+ }));
3158
+ }
3159
+ for (const capability of ['resources', 'prompts']) {
3160
+ if (drift.lists[capability].status === 'warning') {
3161
+ issues.push(repeatDriftIssue('repeat-list-shape-drift', `${capability}/list item count changed across repeat runs`, {
3162
+ capability,
3163
+ drift: drift.lists[capability]
3164
+ }));
3165
+ }
3166
+ }
3167
+ return issues;
3168
+ }
3169
+
3170
+ function repeatDriftIssue(code, message, details) {
3171
+ return {
3172
+ severity: 'warning',
3173
+ code,
3174
+ message,
3175
+ ...details
3176
+ };
3177
+ }
3178
+
3179
+ function advertisedCapabilityKeys(capabilityChecks = {}) {
3180
+ return Object.entries(capabilityChecks)
3181
+ .filter(([, check]) => check?.advertised)
3182
+ .map(([name]) => name)
3183
+ .sort();
3184
+ }
3185
+
3186
+ function sortedUnique(values) {
3187
+ return [...new Set((values ?? []).filter((value) => typeof value === 'string'))].sort();
3188
+ }
3189
+
3190
+ function arrayDifference(left, right) {
3191
+ const rightSet = new Set(right);
3192
+ return left.filter((value) => !rightSet.has(value));
3193
+ }
3194
+
1338
3195
  export function scanSource(root) {
1339
3196
  const findings = [];
1340
3197
  const absoluteRoot = path.resolve(root);
@@ -1344,12 +3201,14 @@ export function scanSource(root) {
1344
3201
  const text = fs.readFileSync(file, 'utf8');
1345
3202
  const lines = text.split(/\r?\n/);
1346
3203
  for (let index = 0; index < lines.length; index += 1) {
1347
- const message = detectStdoutWrite(file, lines[index]);
1348
- if (message) {
3204
+ const finding = detectStdoutRisk(file, lines[index]);
3205
+ if (finding) {
1349
3206
  findings.push({
1350
3207
  file: path.relative(process.cwd(), file).split(path.sep).join('/'),
1351
3208
  line: index + 1,
1352
- message
3209
+ language: finding.language,
3210
+ reason: finding.reason,
3211
+ message: finding.message
1353
3212
  });
1354
3213
  }
1355
3214
  }
@@ -1358,34 +3217,254 @@ export function scanSource(root) {
1358
3217
  return findings;
1359
3218
  }
1360
3219
 
1361
- function detectStdoutWrite(file, line) {
1362
- const ext = path.extname(file);
3220
+ function detectStdoutRisk(file, line) {
3221
+ const ext = path.extname(file).toLowerCase();
1363
3222
  const stripped = line.trim();
1364
3223
  if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
3224
+ const code = normalizeSourceLine(line, ext);
3225
+
3226
+ if (isJavaScriptSource(ext)) {
3227
+ if (/\bconsole\.(log|info)\s*\(/.test(code)) {
3228
+ return staticFinding(
3229
+ 'javascript',
3230
+ 'javascript-console-stdout',
3231
+ 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics'
3232
+ );
3233
+ }
3234
+
3235
+ if (/\bprocess\.stdout\.write\s*\(/.test(code)) {
3236
+ return staticFinding(
3237
+ 'javascript',
3238
+ 'javascript-process-stdout-write',
3239
+ 'direct process.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
3240
+ );
3241
+ }
3242
+
3243
+ if (/\bprocess\.stdout\.(clearLine|cursorTo|moveCursor)\s*\(/.test(code)) {
3244
+ return staticFinding(
3245
+ 'javascript',
3246
+ 'javascript-stdout-terminal-control',
3247
+ 'process.stdout terminal control writes to stdout; keep MCP stdout reserved for JSON-RPC frames'
3248
+ );
3249
+ }
3250
+
3251
+ if (/\bdotenv\.config\s*\(\s*\{[^}]*\bdebug\s*:\s*true\b/.test(code)) {
3252
+ return staticFinding(
3253
+ 'javascript',
3254
+ 'javascript-dotenv-debug-output',
3255
+ 'dotenv debug output can print during startup; keep MCP diagnostics on stderr'
3256
+ );
3257
+ }
1365
3258
 
1366
- if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext) && /\bconsole\.(log|info)\s*\(/.test(line)) {
1367
- return 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics';
3259
+ if (/\b(stream|file)\s*:\s*process\.stdout\b/.test(code)) {
3260
+ return staticFinding(
3261
+ 'javascript',
3262
+ 'javascript-progress-stdout',
3263
+ 'progress or spinner output is configured for stdout; MCP stdout must stay JSON-RPC only'
3264
+ );
3265
+ }
1368
3266
  }
1369
3267
 
1370
- if (ext === '.py' && /(^|[^\w])print\s*\(/.test(line) && !/file\s*=\s*sys\.stderr/.test(line)) {
1371
- return 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics';
3268
+ if (ext === '.py') {
3269
+ if (/(^|[^\w.])print\s*\(/.test(code) && !/file\s*=\s*sys\.stderr/.test(code)) {
3270
+ return staticFinding(
3271
+ 'python',
3272
+ 'python-print-stdout',
3273
+ 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics'
3274
+ );
3275
+ }
3276
+
3277
+ if (/\bsys\.stdout\.write\s*\(/.test(code)) {
3278
+ return staticFinding(
3279
+ 'python',
3280
+ 'python-sys-stdout-write',
3281
+ 'direct sys.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
3282
+ );
3283
+ }
3284
+
3285
+ if (/\blogging\.StreamHandler\s*\(\s*sys\.stdout\s*\)/.test(code)
3286
+ || /\blogging\.basicConfig\s*\([^)]*\bstream\s*=\s*sys\.stdout\b/.test(code)) {
3287
+ return staticFinding(
3288
+ 'python',
3289
+ 'python-logging-stdout-handler',
3290
+ 'Python logging is configured to write to stdout; route MCP diagnostics to stderr'
3291
+ );
3292
+ }
3293
+
3294
+ if (/\btqdm\s*\([^)]*\bfile\s*=\s*sys\.stdout\b/.test(code)) {
3295
+ return staticFinding(
3296
+ 'python',
3297
+ 'python-progress-stdout',
3298
+ 'progress output is configured for stdout; MCP stdout must stay JSON-RPC only'
3299
+ );
3300
+ }
1372
3301
  }
1373
3302
 
1374
- if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(line)) {
1375
- return 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics';
3303
+ if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(code)) {
3304
+ return staticFinding(
3305
+ 'go',
3306
+ 'go-fmt-stdout',
3307
+ 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics'
3308
+ );
1376
3309
  }
1377
3310
 
1378
- if (ext === '.rs' && /\bprintln!\s*\(/.test(line)) {
1379
- return 'println! writes to stdout; use eprintln! for MCP stdio diagnostics';
3311
+ if (ext === '.rs' && /\bprintln!\s*\(/.test(code)) {
3312
+ return staticFinding(
3313
+ 'rust',
3314
+ 'rust-println-stdout',
3315
+ 'println! writes to stdout; use eprintln! for MCP stdio diagnostics'
3316
+ );
1380
3317
  }
1381
3318
 
1382
- if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(line)) {
1383
- return 'System.out writes to stdout; use stderr for MCP stdio diagnostics';
3319
+ if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(code)) {
3320
+ return staticFinding(
3321
+ 'jvm',
3322
+ 'jvm-system-out',
3323
+ 'System.out writes to stdout; use stderr for MCP stdio diagnostics'
3324
+ );
1384
3325
  }
1385
3326
 
1386
3327
  return '';
1387
3328
  }
1388
3329
 
3330
+ function staticFinding(language, reason, message) {
3331
+ return { language, reason, message };
3332
+ }
3333
+
3334
+ function isJavaScriptSource(ext) {
3335
+ return ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext);
3336
+ }
3337
+
3338
+ function normalizeSourceLine(line, ext) {
3339
+ return stripInlineComment(maskStringLiterals(line), ext);
3340
+ }
3341
+
3342
+ function stripInlineComment(line, ext) {
3343
+ if (ext === '.py') {
3344
+ return line.split('#')[0];
3345
+ }
3346
+
3347
+ if (isJavaScriptSource(ext) || ['.go', '.rs', '.java', '.kt'].includes(ext)) {
3348
+ return stripJavaScriptLikeInlineComment(line);
3349
+ }
3350
+
3351
+ return line;
3352
+ }
3353
+
3354
+ function stripJavaScriptLikeInlineComment(line) {
3355
+ let inRegex = false;
3356
+ let escaped = false;
3357
+ let inCharClass = false;
3358
+
3359
+ for (let index = 0; index < line.length; index += 1) {
3360
+ const char = line[index];
3361
+ const next = line[index + 1];
3362
+
3363
+ if (inRegex) {
3364
+ if (escaped) {
3365
+ escaped = false;
3366
+ } else if (char === '\\') {
3367
+ escaped = true;
3368
+ } else if (char === '[') {
3369
+ inCharClass = true;
3370
+ } else if (char === ']') {
3371
+ inCharClass = false;
3372
+ } else if (char === '/' && !inCharClass) {
3373
+ inRegex = false;
3374
+ }
3375
+ continue;
3376
+ }
3377
+
3378
+ if (char === '/' && next === '/') {
3379
+ return line.slice(0, index);
3380
+ }
3381
+
3382
+ if (char === '/' && next === '*') {
3383
+ return line.slice(0, index);
3384
+ }
3385
+
3386
+ if (char === '/' && canStartJavaScriptRegex(line, index)) {
3387
+ inRegex = true;
3388
+ }
3389
+ }
3390
+
3391
+ return line;
3392
+ }
3393
+
3394
+ function canStartJavaScriptRegex(line, index) {
3395
+ const before = line.slice(0, index).trimEnd();
3396
+ if (!before) return true;
3397
+
3398
+ const last = before[before.length - 1];
3399
+ if ('=(:,[!&|?;{}>'.includes(last)) return true;
3400
+
3401
+ return /\b(await|case|delete|of|return|throw|typeof|void|yield)$/.test(before);
3402
+ }
3403
+
3404
+ function maskStringLiterals(line) {
3405
+ let quote = '';
3406
+ let escaped = false;
3407
+ let masked = '';
3408
+ let templateExpressionDepth = 0;
3409
+
3410
+ for (let index = 0; index < line.length; index += 1) {
3411
+ const char = line[index];
3412
+ const next = line[index + 1];
3413
+
3414
+ if (quote) {
3415
+ if (quote === '`' && char === '$' && next === '{') {
3416
+ quote = '';
3417
+ templateExpressionDepth += 1;
3418
+ masked += ' ';
3419
+ index += 1;
3420
+ continue;
3421
+ }
3422
+
3423
+ if (escaped) {
3424
+ escaped = false;
3425
+ } else if (char === '\\') {
3426
+ escaped = true;
3427
+ } else if (char === quote) {
3428
+ quote = '';
3429
+ }
3430
+ masked += ' ';
3431
+ continue;
3432
+ }
3433
+
3434
+ if (templateExpressionDepth > 0) {
3435
+ if (char === '"' || char === "'" || char === '`') {
3436
+ quote = char;
3437
+ masked += ' ';
3438
+ continue;
3439
+ }
3440
+
3441
+ if (char === '{') {
3442
+ templateExpressionDepth += 1;
3443
+ } else if (char === '}') {
3444
+ templateExpressionDepth -= 1;
3445
+ if (templateExpressionDepth === 0) {
3446
+ quote = '`';
3447
+ masked += ' ';
3448
+ continue;
3449
+ }
3450
+ }
3451
+
3452
+ masked += char;
3453
+ continue;
3454
+ }
3455
+
3456
+ if (char === '"' || char === "'" || char === '`') {
3457
+ quote = char;
3458
+ masked += ' ';
3459
+ continue;
3460
+ }
3461
+
3462
+ masked += char;
3463
+ }
3464
+
3465
+ return masked;
3466
+ }
3467
+
1389
3468
  function listSourceFiles(root) {
1390
3469
  const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
1391
3470
  const files = [];
@@ -1431,6 +3510,15 @@ function formatTextResult(result) {
1431
3510
  lines.push(`request: ${result.operation.method} ${state}`);
1432
3511
  }
1433
3512
 
3513
+ if (result.toolSchema?.checked) {
3514
+ lines.push(`tool schemas: ${result.toolSchema.validToolCount}/${result.toolSchema.toolCount} valid`);
3515
+ }
3516
+
3517
+ if (result.adversarial?.enabled) {
3518
+ const passedProbes = result.adversarial.probes.filter((probe) => probe.status === 'pass').length;
3519
+ lines.push(`adversarial probes: ${passedProbes}/${result.adversarial.probes.length} passed`);
3520
+ }
3521
+
1434
3522
  if (result.staticFindings.length) {
1435
3523
  lines.push(`static findings: ${result.staticFindings.length}`);
1436
3524
  for (const finding of result.staticFindings.slice(0, 10)) {
@@ -1453,11 +3541,24 @@ function formatRepeatedTextResult(result) {
1453
3541
  `runs: ${passedRuns}/${result.runs.length} passed`
1454
3542
  ];
1455
3543
 
3544
+ if (result.drift?.checked && result.drift.status !== 'skipped') {
3545
+ const issueCodes = result.drift.issueCodes.length ? ` (${result.drift.issueCodes.join(', ')})` : '';
3546
+ lines.push(`drift: ${result.drift.status}${issueCodes}`);
3547
+ }
3548
+
3549
+ if (result.adversarial?.enabled) {
3550
+ const passedProbes = result.adversarial.probes.filter((probe) => probe.status === 'pass').length;
3551
+ lines.push(`adversarial probes: ${passedProbes}/${result.adversarial.probes.length} passed`);
3552
+ }
3553
+
1456
3554
  for (const run of result.runs) {
1457
3555
  const runStatus = run.ok ? 'PASS' : 'FAIL';
1458
3556
  const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
1459
3557
  const stderrLines = run.stderr ? run.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
1460
- lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines`);
3558
+ const toolSchemas = run.toolSchema?.checked
3559
+ ? `, tool schemas ${run.toolSchema.validToolCount}/${run.toolSchema.toolCount} valid`
3560
+ : '';
3561
+ lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines${toolSchemas}`);
1461
3562
  }
1462
3563
 
1463
3564
  if (result.staticFindings.length) {
@@ -1505,6 +3606,8 @@ Usage:
1505
3606
  mcp-stdio-guard [options] -- <command> [args...]
1506
3607
 
1507
3608
  Options:
3609
+ --config <path> read JSON config for registry runs and safe tool calls
3610
+ --profile <name> guard profile: custom, smoke, registry, ci, strict
1508
3611
  --protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
1509
3612
  --timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
1510
3613
  --repeat <count> run the guard multiple times, default 1
@@ -1512,6 +3615,10 @@ Options:
1512
3615
  --fail-on-static fail when --scan finds risky stdout writes
1513
3616
  --request <method> send one MCP request after initialize, e.g. tools/list
1514
3617
  --params <json> JSON params for --request
3618
+ --adversarial-probe <name>
3619
+ opt into strict probes: ${[...BUILTIN_ADVERSARIAL_PROBE_NAMES, 'all', 'none'].join(', ')}
3620
+ --adversarial-probes <list>
3621
+ comma-separated form of --adversarial-probe
1515
3622
  --json print JSON output
1516
3623
  --cwd <path> run command from this directory
1517
3624
  --version, -v print version