mcp-stdio-guard 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +144 -13
  2. package/package.json +1 -1
  3. package/src/index.js +1558 -76
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.4.0';
10
10
  } catch {
11
- return '0.3.0';
11
+ return '0.4.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; reserved for opt-in adversarial 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,23 @@ 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 CAPABILITY_ISSUE_CODES = new Set([
98
+ 'capability-list-error',
99
+ 'capability-list-missing-response',
100
+ 'capability-list-timeout',
101
+ 'capability-list-unsupported'
102
+ ]);
103
+ const REPEAT_DRIFT_ISSUE_CODES = new Set([
104
+ 'repeat-capability-drift',
105
+ 'repeat-list-shape-drift',
106
+ 'repeat-protocol-drift',
107
+ 'repeat-tool-drift'
108
+ ]);
73
109
  const INITIALIZE_ISSUE_CODES = new Set([
74
110
  'initialize-error',
75
111
  'initialize-invalid-capabilities',
@@ -88,6 +124,15 @@ const OPERATION_ISSUE_CODES = new Set([
88
124
  'operation-missing-response',
89
125
  'operation-timeout'
90
126
  ]);
127
+ const TOOL_SCHEMA_ISSUE_CODES = new Set([
128
+ 'tool-description-missing',
129
+ 'tool-input-schema-invalid',
130
+ 'tool-input-schema-required-missing',
131
+ 'tool-name-duplicate',
132
+ 'tool-name-invalid',
133
+ 'tools-list-invalid-result'
134
+ ]);
135
+ const JSON_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
91
136
  const PROCESS_ISSUE_CODES = new Set([
92
137
  'server-crashed',
93
138
  'server-exited',
@@ -117,6 +162,20 @@ const ISSUE_CLASS_BY_CODE = new Map([
117
162
  ['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
118
163
  ['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
119
164
  ['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
165
+ ['capability-list-error', ISSUE_CLASSES.MCP_PROTOCOL],
166
+ ['capability-list-missing-response', ISSUE_CLASSES.MCP_PROTOCOL],
167
+ ['capability-list-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
168
+ ['capability-list-unsupported', ISSUE_CLASSES.MCP_PROTOCOL],
169
+ ['repeat-capability-drift', ISSUE_CLASSES.MCP_PROTOCOL],
170
+ ['repeat-list-shape-drift', ISSUE_CLASSES.MCP_PROTOCOL],
171
+ ['repeat-protocol-drift', ISSUE_CLASSES.MCP_PROTOCOL],
172
+ ['repeat-tool-drift', ISSUE_CLASSES.MCP_PROTOCOL],
173
+ ['tool-description-missing', ISSUE_CLASSES.MCP_PROTOCOL],
174
+ ['tool-input-schema-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
175
+ ['tool-input-schema-required-missing', ISSUE_CLASSES.MCP_PROTOCOL],
176
+ ['tool-name-duplicate', ISSUE_CLASSES.MCP_PROTOCOL],
177
+ ['tool-name-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
178
+ ['tools-list-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
120
179
  ['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
121
180
  ['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
122
181
  ['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
@@ -142,13 +201,18 @@ export async function runCli(argv) {
142
201
  }
143
202
 
144
203
  const guardOptions = {
204
+ config: options.config,
205
+ profile: options.profile,
145
206
  protocol: options.protocol,
146
207
  timeoutMs: options.timeoutMs,
147
208
  cwd: options.cwd,
148
- operation: options.requestMethod
209
+ env: options.env,
210
+ probeCapabilities: options.probeCapabilities,
211
+ operations: options.operations,
212
+ operation: options.operations.length === 1
149
213
  ? {
150
- method: options.requestMethod,
151
- params: options.requestParams
214
+ method: options.operations[0].method,
215
+ params: options.operations[0].params
152
216
  }
153
217
  : null
154
218
  };
@@ -194,8 +258,15 @@ export async function runCli(argv) {
194
258
  export function parseArgs(argv) {
195
259
  const options = {
196
260
  command: [],
261
+ configPath: '',
262
+ config: defaultConfigMetadata(),
263
+ env: {},
264
+ operations: [],
265
+ configOperations: [],
197
266
  protocol: DEFAULT_PROTOCOL,
198
267
  timeoutMs: DEFAULT_TIMEOUT,
268
+ profile: DEFAULT_PROFILE,
269
+ probeCapabilities: true,
199
270
  scanPath: '',
200
271
  failOnStatic: false,
201
272
  requestMethod: '',
@@ -206,12 +277,14 @@ export function parseArgs(argv) {
206
277
  version: false,
207
278
  cwd: process.cwd()
208
279
  };
280
+ const specifiedOptions = new Set();
209
281
 
210
282
  for (let index = 0; index < argv.length; index += 1) {
211
283
  const arg = argv[index];
212
284
 
213
285
  if (arg === '--') {
214
286
  options.command = argv.slice(index + 1);
287
+ specifiedOptions.add('command');
215
288
  break;
216
289
  }
217
290
 
@@ -221,34 +294,61 @@ export function parseArgs(argv) {
221
294
  options.version = true;
222
295
  } else if (arg === '--json') {
223
296
  options.json = true;
297
+ specifiedOptions.add('json');
298
+ } else if (arg === '--config') {
299
+ options.configPath = path.resolve(readOptionValue(argv, index, arg));
300
+ specifiedOptions.add('configPath');
301
+ index += 1;
302
+ } else if (arg === '--profile') {
303
+ options.profile = readOptionValue(argv, index, arg);
304
+ specifiedOptions.add('profile');
305
+ index += 1;
224
306
  } else if (arg === '--fail-on-static') {
225
307
  options.failOnStatic = true;
308
+ specifiedOptions.add('failOnStatic');
226
309
  } else if (arg === '--request') {
227
310
  options.requestMethod = readOptionValue(argv, index, arg);
311
+ specifiedOptions.add('requestMethod');
228
312
  index += 1;
229
313
  } else if (arg === '--params') {
230
314
  options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
315
+ specifiedOptions.add('requestParams');
231
316
  index += 1;
232
317
  } else if (arg === '--protocol') {
233
318
  options.protocol = readOptionValue(argv, index, arg);
319
+ specifiedOptions.add('protocol');
234
320
  index += 1;
235
321
  } else if (arg === '--timeout') {
236
322
  options.timeoutMs = Number(readOptionValue(argv, index, arg));
323
+ specifiedOptions.add('timeoutMs');
237
324
  index += 1;
238
325
  } else if (arg === '--repeat') {
239
326
  options.repeat = Number(readOptionValue(argv, index, arg));
327
+ specifiedOptions.add('repeat');
240
328
  index += 1;
241
329
  } else if (arg === '--scan') {
242
330
  options.scanPath = path.resolve(readOptionValue(argv, index, arg));
331
+ specifiedOptions.add('scanPath');
243
332
  index += 1;
244
333
  } else if (arg === '--cwd') {
245
334
  options.cwd = path.resolve(readOptionValue(argv, index, arg));
335
+ specifiedOptions.add('cwd');
246
336
  index += 1;
247
337
  } else {
248
338
  throw new Error(`Unknown option before --: ${arg}`);
249
339
  }
250
340
  }
251
341
 
342
+ if (options.help || options.version) {
343
+ return options;
344
+ }
345
+
346
+ if (options.configPath) {
347
+ applyConfigFile(options, loadConfigFile(options.configPath), specifiedOptions);
348
+ }
349
+ applyProfileDefaults(options, specifiedOptions);
350
+ options.operations = buildConfiguredOperations(options);
351
+
252
352
  if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
253
353
  throw new Error('--timeout must be an integer >= 100');
254
354
  }
@@ -264,6 +364,275 @@ export function parseArgs(argv) {
264
364
  return options;
265
365
  }
266
366
 
367
+ function applyProfileDefaults(options, specifiedOptions) {
368
+ if (!GUARD_PROFILE_NAMES.includes(options.profile)) {
369
+ throw new Error(`--profile must be one of: ${GUARD_PROFILE_NAMES.join(', ')}`);
370
+ }
371
+
372
+ if (options.profile === 'smoke') {
373
+ options.probeCapabilities = false;
374
+ return options;
375
+ }
376
+
377
+ options.probeCapabilities = true;
378
+
379
+ if (options.profile === 'registry' && !specifiedOptions.has('repeat')) {
380
+ options.repeat = 2;
381
+ }
382
+
383
+ if (options.profile === 'ci') {
384
+ options.json = true;
385
+ options.failOnStatic = true;
386
+ }
387
+
388
+ if (options.profile === 'strict') {
389
+ options.json = true;
390
+ options.failOnStatic = true;
391
+ if (!specifiedOptions.has('repeat')) {
392
+ options.repeat = 2;
393
+ }
394
+ }
395
+
396
+ return options;
397
+ }
398
+
399
+ function loadConfigFile(configPath) {
400
+ let raw;
401
+ try {
402
+ raw = fs.readFileSync(configPath, 'utf8');
403
+ } catch (error) {
404
+ throw new Error(`Failed to read --config ${configPath}: ${error.message}`);
405
+ }
406
+
407
+ try {
408
+ const parsed = JSON.parse(raw);
409
+ if (!isObjectRecord(parsed)) {
410
+ throw new Error('top-level value must be an object');
411
+ }
412
+ return parsed;
413
+ } catch (error) {
414
+ throw new Error(`--config ${configPath} must be valid JSON: ${error.message}`);
415
+ }
416
+ }
417
+
418
+ function applyConfigFile(options, config, specifiedOptions) {
419
+ const configDir = path.dirname(options.configPath);
420
+ const command = normalizeConfigCommand(config);
421
+ const env = normalizeConfigEnv(config);
422
+ const requests = normalizeConfigRequests(config);
423
+ const safeToolCalls = normalizeSafeToolCalls(config);
424
+ const usesConfigCommand = command.length > 0 && !specifiedOptions.has('command');
425
+ const usesConfigCwd = typeof config.cwd === 'string' && !specifiedOptions.has('cwd');
426
+ const usesConfigOperations = !specifiedOptions.has('requestMethod') && !specifiedOptions.has('requestParams');
427
+
428
+ if (usesConfigCommand) {
429
+ options.command = command;
430
+ }
431
+
432
+ if (usesConfigCwd) {
433
+ options.cwd = resolveConfigPath(config.cwd, configDir);
434
+ } else if (config.cwd !== undefined && typeof config.cwd !== 'string') {
435
+ throw new Error('--config cwd must be a string');
436
+ }
437
+
438
+ if (config.protocol !== undefined && !specifiedOptions.has('protocol')) {
439
+ if (typeof config.protocol !== 'string' || !config.protocol) {
440
+ throw new Error('--config protocol must be a non-empty string');
441
+ }
442
+ options.protocol = config.protocol;
443
+ }
444
+
445
+ const timeoutField = Object.hasOwn(config, 'timeoutMs') ? 'timeoutMs' : 'timeout';
446
+ const timeoutValue = config[timeoutField];
447
+ if (timeoutValue !== undefined && !specifiedOptions.has('timeoutMs')) {
448
+ options.timeoutMs = normalizeConfigInteger(timeoutValue, timeoutField, 100);
449
+ }
450
+
451
+ if (config.repeat !== undefined && !specifiedOptions.has('repeat')) {
452
+ options.repeat = normalizeConfigInteger(config.repeat, 'repeat', 1);
453
+ }
454
+
455
+ if (config.profile !== undefined && !specifiedOptions.has('profile')) {
456
+ if (typeof config.profile !== 'string') {
457
+ throw new Error('--config profile must be a string');
458
+ }
459
+ options.profile = config.profile;
460
+ }
461
+
462
+ if (config.json !== undefined && !specifiedOptions.has('json')) {
463
+ if (typeof config.json !== 'boolean') {
464
+ throw new Error('--config json must be a boolean');
465
+ }
466
+ options.json = config.json;
467
+ }
468
+
469
+ const scanPath = config.scanPath ?? config.scan;
470
+ if (scanPath !== undefined && !specifiedOptions.has('scanPath')) {
471
+ if (typeof scanPath !== 'string') {
472
+ throw new Error('--config scan must be a string path');
473
+ }
474
+ options.scanPath = resolveConfigPath(scanPath, configDir);
475
+ }
476
+
477
+ if (config.failOnStatic !== undefined && !specifiedOptions.has('failOnStatic')) {
478
+ if (typeof config.failOnStatic !== 'boolean') {
479
+ throw new Error('--config failOnStatic must be a boolean');
480
+ }
481
+ options.failOnStatic = config.failOnStatic;
482
+ }
483
+
484
+ if (Object.keys(env).length) {
485
+ options.env = { ...env, ...options.env };
486
+ }
487
+
488
+ if (usesConfigOperations) {
489
+ options.configOperations = [...requests, ...safeToolCalls];
490
+ }
491
+
492
+ options.config = {
493
+ enabled: true,
494
+ path: options.configPath,
495
+ resolvedPath: options.configPath,
496
+ checks: {
497
+ command: usesConfigCommand,
498
+ cwd: usesConfigCwd,
499
+ envNames: Object.keys(env).sort(),
500
+ requests: usesConfigOperations ? requests.map((request) => request.method) : [],
501
+ safeToolCalls: usesConfigOperations ? safeToolCalls.map((request) => request.safeToolCallName) : []
502
+ }
503
+ };
504
+ }
505
+
506
+ function normalizeConfigCommand(config) {
507
+ if (config.command === undefined && config.args === undefined) return [];
508
+
509
+ if (Array.isArray(config.command)) {
510
+ if (!config.command.every((entry) => typeof entry === 'string' && entry)) {
511
+ throw new Error('--config command array entries must be non-empty strings');
512
+ }
513
+ if (config.args !== undefined) {
514
+ throw new Error('--config args cannot be used when command is an array');
515
+ }
516
+ return config.command;
517
+ }
518
+
519
+ if (typeof config.command === 'string' && config.command) {
520
+ const args = config.args ?? [];
521
+ if (!Array.isArray(args) || !args.every((entry) => typeof entry === 'string')) {
522
+ throw new Error('--config args must be an array of strings');
523
+ }
524
+ return [config.command, ...args];
525
+ }
526
+
527
+ throw new Error('--config command must be a non-empty string or array of strings');
528
+ }
529
+
530
+ function normalizeConfigEnv(config) {
531
+ if (config.env === undefined) return {};
532
+ if (!isObjectRecord(config.env)) {
533
+ throw new Error('--config env must be an object');
534
+ }
535
+
536
+ return Object.fromEntries(Object.entries(config.env).map(([name, value]) => {
537
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
538
+ throw new Error(`--config env contains invalid variable name: ${name}`);
539
+ }
540
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
541
+ throw new Error(`--config env.${name} must be a string, number, or boolean`);
542
+ }
543
+ return [name, String(value)];
544
+ }));
545
+ }
546
+
547
+ function normalizeConfigInteger(value, field, minimum) {
548
+ const normalized = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
549
+ if (!Number.isInteger(normalized) || normalized < minimum) {
550
+ throw new Error(`--config ${field} must be an integer >= ${minimum}`);
551
+ }
552
+ return normalized;
553
+ }
554
+
555
+ function normalizeConfigRequests(config) {
556
+ const values = [];
557
+ if (config.request !== undefined) {
558
+ values.push(config.request);
559
+ }
560
+ if (config.requests !== undefined) {
561
+ if (!Array.isArray(config.requests)) {
562
+ throw new Error('--config requests must be an array');
563
+ }
564
+ values.push(...config.requests);
565
+ }
566
+
567
+ return values.map((request, index) => {
568
+ if (!isObjectRecord(request)) {
569
+ throw new Error(`--config requests[${index}] must be an object`);
570
+ }
571
+ if (typeof request.method !== 'string' || !request.method) {
572
+ throw new Error(`--config requests[${index}].method must be a non-empty string`);
573
+ }
574
+ return {
575
+ source: 'config-request',
576
+ method: request.method,
577
+ params: request.params
578
+ };
579
+ });
580
+ }
581
+
582
+ function normalizeSafeToolCalls(config) {
583
+ if (config.safeToolCalls === undefined) return [];
584
+ if (!Array.isArray(config.safeToolCalls)) {
585
+ throw new Error('--config safeToolCalls must be an array');
586
+ }
587
+
588
+ return config.safeToolCalls.map((call, index) => {
589
+ if (!isObjectRecord(call)) {
590
+ throw new Error(`--config safeToolCalls[${index}] must be an object`);
591
+ }
592
+ if (typeof call.name !== 'string' || !call.name) {
593
+ throw new Error(`--config safeToolCalls[${index}].name must be a non-empty string`);
594
+ }
595
+ const argumentsValue = call.arguments ?? {};
596
+ if (!isObjectRecord(argumentsValue)) {
597
+ throw new Error(`--config safeToolCalls[${index}].arguments must be an object`);
598
+ }
599
+ return {
600
+ source: 'safe-tool-call',
601
+ method: 'tools/call',
602
+ params: {
603
+ name: call.name,
604
+ arguments: argumentsValue
605
+ },
606
+ safeToolCallName: call.name
607
+ };
608
+ });
609
+ }
610
+
611
+ function buildConfiguredOperations(options) {
612
+ if (options.requestMethod) {
613
+ return [{
614
+ source: 'cli-request',
615
+ method: options.requestMethod,
616
+ params: options.requestParams
617
+ }];
618
+ }
619
+
620
+ return options.configOperations;
621
+ }
622
+
623
+ function normalizeGuardOperations(operations) {
624
+ return operations.map((operation) => ({
625
+ source: operation.source ?? 'request',
626
+ method: operation.method,
627
+ params: operation.params,
628
+ safeToolCallName: operation.safeToolCallName ?? ''
629
+ }));
630
+ }
631
+
632
+ function resolveConfigPath(value, configDir) {
633
+ return path.resolve(configDir, value);
634
+ }
635
+
267
636
  export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
268
637
  const startedAt = Date.now();
269
638
  const repeat = options.repeat ?? 1;
@@ -285,18 +654,28 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
285
654
  }
286
655
  }
287
656
 
657
+ const drift = buildRepeatDrift(runs);
658
+ for (const issue of repeatDriftIssues(drift)) {
659
+ issues.push(issue);
660
+ }
661
+
288
662
  const durationMs = Date.now() - startedAt;
289
663
  const result = {
290
664
  schemaVersion: JSON_SCHEMA_VERSION,
291
665
  ok: !issues.some((issue) => issue.severity === 'error'),
666
+ profile: options.profile ?? DEFAULT_PROFILE,
292
667
  command: commandWithArgs,
668
+ config: options.config ?? defaultConfigMetadata(),
293
669
  protocol: options.protocol ?? DEFAULT_PROTOCOL,
294
670
  repeat,
295
671
  runs,
296
672
  issues,
297
673
  checks: {},
674
+ capabilityProbes: options.probeCapabilities ?? true,
675
+ drift,
298
676
  staticScan: defaultStaticScan(),
299
677
  staticFindings: [],
678
+ toolSchema: aggregateRunToolSchemaValidation(runs),
300
679
  durationMs,
301
680
  fingerprint: createFingerprint(commandWithArgs, options)
302
681
  };
@@ -310,7 +689,8 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
310
689
  const args = commandWithArgs.slice(1);
311
690
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
312
691
  const protocol = options.protocol ?? DEFAULT_PROTOCOL;
313
- const operation = options.operation || null;
692
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
693
+ const probeCapabilities = options.probeCapabilities ?? true;
314
694
  const env = { ...process.env, ...(options.env ?? {}) };
315
695
  const issues = [];
316
696
  const frames = [];
@@ -319,36 +699,58 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
319
699
  let initialized = false;
320
700
  let endedByGuard = false;
321
701
  let initializeResponseAt = 0;
702
+ let currentRequest = null;
703
+ let requestQueue = [];
704
+ let nextRequestId = 2;
322
705
  let timer;
323
706
  let child;
324
707
 
325
708
  const result = {
326
709
  schemaVersion: JSON_SCHEMA_VERSION,
327
710
  ok: false,
711
+ profile: options.profile ?? DEFAULT_PROFILE,
328
712
  command: commandWithArgs,
713
+ config: options.config ?? defaultConfigMetadata(),
329
714
  protocol,
330
715
  negotiatedProtocol: '',
331
716
  initialized: false,
332
- operation: operation
717
+ operation: operations.length === 1
333
718
  ? {
334
- method: operation.method,
719
+ method: operations[0].method,
720
+ source: operations[0].source,
721
+ safeToolCallName: operations[0].safeToolCallName,
335
722
  responded: false,
336
723
  error: null
337
724
  }
338
725
  : null,
726
+ operations: operations.map((operation, index) => ({
727
+ index,
728
+ source: operation.source,
729
+ method: operation.method,
730
+ safeToolCallName: operation.safeToolCallName,
731
+ responded: false,
732
+ error: null
733
+ })),
339
734
  frames,
340
735
  issues,
341
736
  checks: {},
737
+ capabilityProbes: probeCapabilities,
738
+ capabilityKeys: [],
739
+ capabilityChecks: defaultCapabilityChecks(),
342
740
  stderr: '',
343
741
  process: defaultProcessInfo(timeoutMs),
344
742
  staticScan: defaultStaticScan(),
345
743
  staticFindings: [],
744
+ toolSchema: defaultToolSchemaValidation(),
346
745
  durationMs: 0,
347
746
  fingerprint: createFingerprint(commandWithArgs, {
348
747
  protocol,
349
748
  timeoutMs,
350
749
  cwd: options.cwd,
351
- operation,
750
+ config: options.config,
751
+ profile: options.profile,
752
+ probeCapabilities,
753
+ operations,
352
754
  env: options.env
353
755
  })
354
756
  };
@@ -358,7 +760,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
358
760
  issues.push({ ...details, severity, code, message });
359
761
  }
360
762
 
361
- function armTimeout(code, message, timeoutPhase) {
763
+ function armTimeout(code, message, timeoutPhase, details = {}) {
362
764
  clearTimeout(timer);
363
765
  result.process.phase = timeoutPhase;
364
766
  timer = setTimeout(() => {
@@ -366,7 +768,10 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
366
768
  result.process.timeoutCode = code;
367
769
  result.process.timeoutMs = timeoutMs;
368
770
  result.process.outcome = 'timeout';
369
- addIssue('error', code, message, timeoutIssueDetails(code, timeoutMs, timeoutPhase));
771
+ addIssue('error', code, message, {
772
+ ...timeoutIssueDetails(code, timeoutMs, timeoutPhase),
773
+ ...details
774
+ });
370
775
  finish();
371
776
  }, timeoutMs);
372
777
  }
@@ -406,6 +811,162 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
406
811
  child.stdin.write(`${JSON.stringify(message)}\n`);
407
812
  }
408
813
 
814
+ function enqueueRequest(request) {
815
+ requestQueue.push({
816
+ ...request,
817
+ id: nextRequestId
818
+ });
819
+ nextRequestId += 1;
820
+ }
821
+
822
+ function startNextRequest() {
823
+ if (result.durationMs || currentRequest) return;
824
+ currentRequest = requestQueue.shift() || null;
825
+ if (!currentRequest) {
826
+ result.process.phase = 'post-initialize';
827
+ finishSoon();
828
+ return;
829
+ }
830
+
831
+ result.process.phase = 'operation';
832
+ const request = {
833
+ jsonrpc: '2.0',
834
+ id: currentRequest.id,
835
+ method: currentRequest.method
836
+ };
837
+ if (currentRequest.params !== undefined) {
838
+ request.params = currentRequest.params;
839
+ }
840
+ send(request);
841
+ armTimeout(
842
+ currentRequest.timeoutCode,
843
+ currentRequest.timeoutMessage,
844
+ 'operation',
845
+ currentRequest.timeoutDetails
846
+ );
847
+ }
848
+
849
+ function configureCapabilityChecks(capabilities) {
850
+ result.capabilityKeys = capabilityKeys(capabilities);
851
+ for (const definition of CAPABILITY_DEFINITIONS) {
852
+ const check = result.capabilityChecks[definition.name];
853
+ check.advertised = result.capabilityKeys.includes(definition.name);
854
+ }
855
+ }
856
+
857
+ function enqueuePostInitializeRequests() {
858
+ const operationCapabilities = new Set(operations.map((operation) => capabilityNameForMethod(operation.method)).filter(Boolean));
859
+ for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
860
+ const operation = operations[operationIndex];
861
+ const operationCapability = capabilityNameForMethod(operation.method);
862
+ enqueueRequest({
863
+ kind: 'operation',
864
+ operationIndex,
865
+ capability: result.capabilityChecks[operationCapability]?.advertised ? operationCapability : '',
866
+ method: operation.method,
867
+ params: operation.params,
868
+ timeoutCode: 'operation-timeout',
869
+ timeoutMessage: `no ${operation.method} response within ${timeoutMs}ms`,
870
+ timeoutDetails: {}
871
+ });
872
+ }
873
+
874
+ if (!probeCapabilities) return;
875
+
876
+ for (const definition of CAPABILITY_DEFINITIONS) {
877
+ const check = result.capabilityChecks[definition.name];
878
+ if (!check.advertised || operationCapabilities.has(definition.name)) continue;
879
+ enqueueRequest({
880
+ kind: 'capability',
881
+ capability: definition.name,
882
+ method: definition.method,
883
+ timeoutCode: 'capability-list-timeout',
884
+ timeoutMessage: `${definition.method} did not receive a response for advertised ${definition.name} capability within ${timeoutMs}ms`,
885
+ timeoutDetails: {
886
+ capability: definition.name,
887
+ method: definition.method,
888
+ detailCode: 'capability-request-timeout'
889
+ }
890
+ });
891
+ }
892
+ }
893
+
894
+ function handleCurrentRequestResponse(message) {
895
+ clearTimeout(timer);
896
+ const request = currentRequest;
897
+ if (!isJsonRpcResponse(message)) {
898
+ addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id ${request.id} is not a ${request.method} response`);
899
+ finish();
900
+ return;
901
+ }
902
+
903
+ if (request.kind === 'operation') {
904
+ const operationResult = result.operations[request.operationIndex];
905
+ operationResult.responded = true;
906
+ if (result.operation && request.operationIndex === 0) {
907
+ result.operation.responded = true;
908
+ }
909
+ if (message.error) {
910
+ operationResult.error = message.error;
911
+ if (result.operation && request.operationIndex === 0) {
912
+ result.operation.error = message.error;
913
+ }
914
+ addIssue('warning', 'operation-error', `${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
915
+ }
916
+ }
917
+
918
+ if (!message.error && request.method === 'tools/list') {
919
+ const validation = validateToolsListResult(message.result);
920
+ result.toolSchema = validation.summary;
921
+ for (const issue of validation.issues) {
922
+ addIssue(issue.severity, issue.code, issue.message, issue.details);
923
+ }
924
+ }
925
+
926
+ if (request.capability) {
927
+ recordCapabilityListShape(request, message.result);
928
+ handleCapabilityResponse(request, message);
929
+ }
930
+
931
+ currentRequest = null;
932
+ startNextRequest();
933
+ }
934
+
935
+ function handleCapabilityResponse(request, message) {
936
+ const check = result.capabilityChecks[request.capability];
937
+ check.responded = true;
938
+ if (!message.error) return;
939
+
940
+ check.error = message.error;
941
+ const unsupported = isUnsupportedMethodError(message.error);
942
+ const code = unsupported ? 'capability-list-unsupported' : 'capability-list-error';
943
+ const messageText = unsupported
944
+ ? `${request.capability} capability is advertised but ${request.method} returned Method not found`
945
+ : `${request.capability} capability is advertised but ${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`;
946
+ addIssue('error', code, messageText, {
947
+ capability: request.capability,
948
+ method: request.method,
949
+ errorCode: message.error.code ?? null
950
+ });
951
+ }
952
+
953
+ function recordCapabilityListShape(request, responseResult) {
954
+ if (!isObjectRecord(responseResult)) return;
955
+ const check = result.capabilityChecks[request.capability];
956
+ const items = responseResult[request.capability];
957
+ if (check && Array.isArray(items)) {
958
+ check.itemCount = items.length;
959
+ }
960
+ }
961
+
962
+ function addCapabilityMissingResponseIssue(request) {
963
+ addIssue('error', 'capability-list-missing-response', `${request.method} did not receive a response before server exit`, {
964
+ capability: request.capability,
965
+ method: request.method,
966
+ detailCode: 'capability-missing-response'
967
+ });
968
+ }
969
+
409
970
  const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
410
971
  if (pythonBufferingIssue) {
411
972
  addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
@@ -462,7 +1023,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
462
1023
  if (result.durationMs) return;
463
1024
  clearTimeout(timer);
464
1025
  const exitPhase = initialized
465
- ? result.operation && !result.operation.responded
1026
+ ? currentRequest
466
1027
  ? 'operation'
467
1028
  : 'post-initialize'
468
1029
  : 'initialize';
@@ -473,8 +1034,18 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
473
1034
  if (stdoutBuffer.trim()) {
474
1035
  addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
475
1036
  }
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));
1037
+ if (!endedByGuard && initialized && currentRequest?.kind === 'operation') {
1038
+ const operationResult = result.operations[currentRequest.operationIndex];
1039
+ if (operationResult && !operationResult.responded) {
1040
+ addIssue('error', 'operation-missing-response', `${operationResult.method} did not receive a response before server exit`, {
1041
+ ...exitIssueDetails('during-operation', code, signal),
1042
+ operationIndex: currentRequest.operationIndex,
1043
+ method: operationResult.method
1044
+ });
1045
+ }
1046
+ }
1047
+ if (!endedByGuard && initialized && currentRequest?.capability) {
1048
+ addCapabilityMissingResponseIssue(currentRequest);
478
1049
  }
479
1050
  if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
480
1051
  addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
@@ -566,50 +1137,26 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
566
1137
  }
567
1138
 
568
1139
  initialized = true;
569
- result.process.phase = operation ? 'operation' : 'post-initialize';
1140
+ configureCapabilityChecks(message.result.capabilities);
1141
+ result.process.phase = 'post-initialize';
570
1142
  result.negotiatedProtocol = message.result?.protocolVersion || '';
571
1143
  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)) {
1144
+ enqueuePostInitializeRequests();
1145
+ startNextRequest();
1146
+ } else if (initialized && currentRequest && isResponseIdTypeMismatch(message, currentRequest.id)) {
587
1147
  clearTimeout(timer);
588
- addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
1148
+ addIssue('error', 'response-id-type-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not exactly match request id ${currentRequest.id}`);
589
1149
  finish();
590
- } else if (initialized && operation && isResponseIdTypeMismatch(message, 2)) {
1150
+ } else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
591
1151
  clearTimeout(timer);
592
- addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
1152
+ addIssue('error', 'response-id-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not match request id ${currentRequest.id}`);
593
1153
  finish();
594
- } else if (initialized && operation && isResponseIdMismatch(message, 2)) {
1154
+ } else if (initialized && currentRequest && message.id === currentRequest.id) {
1155
+ handleCurrentRequestResponse(message);
1156
+ } else if (initialized && !currentRequest && isJsonRpcResponse(message)) {
595
1157
  clearTimeout(timer);
596
- addIssue('error', 'response-id-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not match request id 2`);
1158
+ addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
597
1159
  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
1160
  }
614
1161
  }
615
1162
  });
@@ -655,12 +1202,41 @@ function defaultProcessInfo(timeoutMs) {
655
1202
 
656
1203
  function timeoutIssueDetails(code, timeoutMs, phase) {
657
1204
  return {
658
- detailCode: code === 'operation-timeout' ? 'request-timeout' : 'startup-timeout',
1205
+ detailCode: code === 'operation-timeout'
1206
+ ? 'request-timeout'
1207
+ : code === 'capability-list-timeout'
1208
+ ? 'capability-request-timeout'
1209
+ : 'startup-timeout',
659
1210
  phase,
660
1211
  timeoutMs
661
1212
  };
662
1213
  }
663
1214
 
1215
+ function defaultCapabilityChecks() {
1216
+ return Object.fromEntries(CAPABILITY_DEFINITIONS.map((definition) => [
1217
+ definition.name,
1218
+ {
1219
+ advertised: false,
1220
+ method: definition.method,
1221
+ responded: false,
1222
+ itemCount: null,
1223
+ error: null
1224
+ }
1225
+ ]));
1226
+ }
1227
+
1228
+ function capabilityNameForMethod(method) {
1229
+ return CAPABILITY_DEFINITIONS.find((definition) => definition.method === method)?.name ?? '';
1230
+ }
1231
+
1232
+ function capabilityKeys(capabilities) {
1233
+ return isObjectRecord(capabilities) ? Object.keys(capabilities).sort() : [];
1234
+ }
1235
+
1236
+ function isUnsupportedMethodError(error) {
1237
+ return error?.code === -32601 || /method not found/i.test(error?.message || '');
1238
+ }
1239
+
664
1240
  function exitIssueDetails(position, code, signal) {
665
1241
  return {
666
1242
  detailCode: exitDetailCode(position, code, signal),
@@ -817,13 +1393,264 @@ function validateInitializeResult(result) {
817
1393
  return issues;
818
1394
  }
819
1395
 
1396
+ function validateToolsListResult(result) {
1397
+ const summary = {
1398
+ ...defaultToolSchemaValidation(),
1399
+ checked: true
1400
+ };
1401
+ const issues = [];
1402
+
1403
+ if (!isObjectRecord(result) || !Array.isArray(result.tools)) {
1404
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', 'tools/list result must be an object with a tools array');
1405
+ summary.errorCount = 1;
1406
+ return { summary, issues };
1407
+ }
1408
+
1409
+ summary.toolCount = result.tools.length;
1410
+ const seenNames = new Map();
1411
+
1412
+ for (let index = 0; index < result.tools.length; index += 1) {
1413
+ const tool = result.tools[index];
1414
+ let toolValid = true;
1415
+
1416
+ if (!isObjectRecord(tool)) {
1417
+ addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', `tools[${index}] must be an object`, { toolIndex: index });
1418
+ continue;
1419
+ }
1420
+
1421
+ const name = validateToolName(tool, index, issues);
1422
+ toolValid = Boolean(name);
1423
+ if (name) {
1424
+ summary.toolNames.push(name);
1425
+ if (seenNames.has(name)) {
1426
+ const firstToolIndex = seenNames.get(name);
1427
+ toolValid = false;
1428
+ summary.duplicateNames.push(name);
1429
+ addToolSchemaIssue(
1430
+ issues,
1431
+ 'error',
1432
+ 'tool-name-duplicate',
1433
+ `tools/list returned duplicate tool name ${JSON.stringify(name)}`,
1434
+ { toolIndex: index, firstToolIndex, toolName: name }
1435
+ );
1436
+ } else {
1437
+ seenNames.set(name, index);
1438
+ }
1439
+ }
1440
+
1441
+ if (typeof tool.description !== 'string' || !tool.description.trim()) {
1442
+ addToolSchemaIssue(
1443
+ issues,
1444
+ 'warning',
1445
+ 'tool-description-missing',
1446
+ name
1447
+ ? `tool ${JSON.stringify(name)} is missing a non-empty description`
1448
+ : `tools[${index}] is missing a non-empty description`,
1449
+ { toolIndex: index, toolName: name || '' }
1450
+ );
1451
+ }
1452
+
1453
+ if (!Object.hasOwn(tool, 'inputSchema')) {
1454
+ addToolSchemaIssue(
1455
+ issues,
1456
+ 'error',
1457
+ 'tool-input-schema-invalid',
1458
+ name
1459
+ ? `tool ${JSON.stringify(name)} is missing inputSchema`
1460
+ : `tools[${index}] is missing inputSchema`,
1461
+ { toolIndex: index, toolName: name || '', schemaPath: 'inputSchema' }
1462
+ );
1463
+ toolValid = false;
1464
+ } else {
1465
+ toolValid = validateInputSchema(tool.inputSchema, {
1466
+ toolIndex: index,
1467
+ toolName: name || '',
1468
+ schemaPath: 'inputSchema'
1469
+ }, issues) && toolValid;
1470
+ }
1471
+
1472
+ if (toolValid) {
1473
+ summary.validToolCount += 1;
1474
+ }
1475
+ }
1476
+
1477
+ summary.duplicateNames = [...new Set(summary.duplicateNames)].sort();
1478
+ summary.toolNames = [...new Set(summary.toolNames)].sort();
1479
+ summary.errorCount = issues.filter((issue) => issue.severity === 'error').length;
1480
+ summary.warningCount = issues.filter((issue) => issue.severity === 'warning').length;
1481
+ return { summary, issues };
1482
+ }
1483
+
1484
+ function validateToolName(tool, index, issues) {
1485
+ if (typeof tool.name !== 'string' || !tool.name.trim() || tool.name !== tool.name.trim()) {
1486
+ addToolSchemaIssue(issues, 'error', 'tool-name-invalid', `tools[${index}] name must be a stable non-empty string`, {
1487
+ toolIndex: index,
1488
+ toolName: typeof tool.name === 'string' ? tool.name : ''
1489
+ });
1490
+ return '';
1491
+ }
1492
+
1493
+ return tool.name;
1494
+ }
1495
+
1496
+ function validateInputSchema(schema, context, issues) {
1497
+ return validateSchemaValue(schema, context, issues);
1498
+ }
1499
+
1500
+ function validateSchemaValue(schema, context, issues) {
1501
+ if (typeof schema === 'boolean') {
1502
+ return true;
1503
+ }
1504
+
1505
+ return validateSchemaObject(schema, context, issues);
1506
+ }
1507
+
1508
+ function validateSchemaObject(schema, context, issues) {
1509
+ if (!isObjectRecord(schema)) {
1510
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a JSON Schema object or boolean schema`, context);
1511
+ return false;
1512
+ }
1513
+
1514
+ let valid = true;
1515
+
1516
+ if (Object.hasOwn(schema, 'type') && !isValidJsonSchemaType(schema.type)) {
1517
+ valid = false;
1518
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.type must be a string or array of strings`, context);
1519
+ }
1520
+
1521
+ if (Object.hasOwn(schema, 'properties')) {
1522
+ if (!isObjectRecord(schema.properties)) {
1523
+ valid = false;
1524
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.properties must be an object`, context);
1525
+ } else {
1526
+ for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
1527
+ valid = validateSchemaValue(propertySchema, {
1528
+ ...context,
1529
+ schemaPath: `${context.schemaPath}.properties.${propertyName}`
1530
+ }, issues) && valid;
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ if (Object.hasOwn(schema, 'required')) {
1536
+ valid = validateRequired(schema, context, issues) && valid;
1537
+ }
1538
+
1539
+ if (Object.hasOwn(schema, 'items')) {
1540
+ valid = validateSchemaValueOrArray(schema.items, {
1541
+ ...context,
1542
+ schemaPath: `${context.schemaPath}.items`
1543
+ }, issues) && valid;
1544
+ }
1545
+
1546
+ if (Object.hasOwn(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') {
1547
+ valid = validateSchemaObject(schema.additionalProperties, {
1548
+ ...context,
1549
+ schemaPath: `${context.schemaPath}.additionalProperties`
1550
+ }, issues) && valid;
1551
+ }
1552
+
1553
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
1554
+ if (Object.hasOwn(schema, keyword)) {
1555
+ valid = validateSchemaArray(schema[keyword], {
1556
+ ...context,
1557
+ schemaPath: `${context.schemaPath}.${keyword}`
1558
+ }, issues) && valid;
1559
+ }
1560
+ }
1561
+
1562
+ if (Object.hasOwn(schema, 'enum') && !Array.isArray(schema.enum)) {
1563
+ valid = false;
1564
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.enum must be an array`, context);
1565
+ }
1566
+
1567
+ return valid;
1568
+ }
1569
+
1570
+ function validateSchemaValueOrArray(value, context, issues) {
1571
+ if (Array.isArray(value)) {
1572
+ return validateSchemaArray(value, context, issues);
1573
+ }
1574
+
1575
+ return validateSchemaValue(value, context, issues);
1576
+ }
1577
+
1578
+ function validateSchemaArray(value, context, issues) {
1579
+ if (!Array.isArray(value) || !value.length) {
1580
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a non-empty array of schemas`, context);
1581
+ return false;
1582
+ }
1583
+
1584
+ let valid = true;
1585
+ for (let index = 0; index < value.length; index += 1) {
1586
+ valid = validateSchemaValue(value[index], {
1587
+ ...context,
1588
+ schemaPath: `${context.schemaPath}[${index}]`
1589
+ }, issues) && valid;
1590
+ }
1591
+ return valid;
1592
+ }
1593
+
1594
+ function validateRequired(schema, context, issues) {
1595
+ if (!Array.isArray(schema.required) || schema.required.some((entry) => typeof entry !== 'string' || !entry)) {
1596
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must be an array of non-empty strings`, context);
1597
+ return false;
1598
+ }
1599
+
1600
+ let valid = true;
1601
+ const seen = new Set();
1602
+ for (const propertyName of schema.required) {
1603
+ if (seen.has(propertyName)) {
1604
+ valid = false;
1605
+ addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must not contain duplicate entries`, {
1606
+ ...context,
1607
+ propertyName
1608
+ });
1609
+ }
1610
+ seen.add(propertyName);
1611
+
1612
+ if (!isObjectRecord(schema.properties) || !Object.hasOwn(schema.properties, propertyName)) {
1613
+ valid = false;
1614
+ addToolSchemaIssue(
1615
+ issues,
1616
+ 'error',
1617
+ 'tool-input-schema-required-missing',
1618
+ `${context.schemaPath}.required references missing property ${JSON.stringify(propertyName)}`,
1619
+ { ...context, propertyName }
1620
+ );
1621
+ }
1622
+ }
1623
+
1624
+ return valid;
1625
+ }
1626
+
1627
+ function isValidJsonSchemaType(value) {
1628
+ if (typeof value === 'string') {
1629
+ return JSON_SCHEMA_TYPES.has(value);
1630
+ }
1631
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && JSON_SCHEMA_TYPES.has(entry));
1632
+ }
1633
+
1634
+ function addToolSchemaIssue(issues, severity, code, message, details = {}) {
1635
+ issues.push({
1636
+ severity,
1637
+ code,
1638
+ message,
1639
+ details
1640
+ });
1641
+ }
1642
+
1643
+ function isObjectRecord(value) {
1644
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1645
+ }
1646
+
820
1647
  export function classifyIssueCode(code) {
821
1648
  return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
822
1649
  }
823
1650
 
824
1651
  export function createFingerprint(commandWithArgs, options = {}) {
825
1652
  const cwd = path.resolve(options.cwd ?? process.cwd());
826
- const operation = options.operation || null;
1653
+ const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
827
1654
 
828
1655
  return {
829
1656
  guard: {
@@ -841,14 +1668,25 @@ export function createFingerprint(commandWithArgs, options = {}) {
841
1668
  exists: fs.existsSync(cwd)
842
1669
  },
843
1670
  protocol: options.protocol ?? DEFAULT_PROTOCOL,
1671
+ config: options.config ?? defaultConfigMetadata(),
1672
+ profile: options.profile ?? DEFAULT_PROFILE,
844
1673
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
845
1674
  repeat: options.repeat ?? 1,
846
- operation: operation
1675
+ capabilityProbes: options.probeCapabilities ?? true,
1676
+ operation: operations.length === 1
847
1677
  ? {
848
- method: operation.method,
849
- hasParams: operation.params !== undefined
1678
+ method: operations[0].method,
1679
+ hasParams: operations[0].params !== undefined,
1680
+ source: operations[0].source,
1681
+ safeToolCallName: operations[0].safeToolCallName
850
1682
  }
851
1683
  : null,
1684
+ operations: operations.map((operation) => ({
1685
+ method: operation.method,
1686
+ hasParams: operation.params !== undefined,
1687
+ source: operation.source,
1688
+ safeToolCallName: operation.safeToolCallName
1689
+ })),
852
1690
  system: {
853
1691
  platform: process.platform,
854
1692
  arch: process.arch,
@@ -1171,6 +2009,17 @@ function isNpxCommand(base) {
1171
2009
 
1172
2010
  function finalizeResult(result) {
1173
2011
  result.schemaVersion = JSON_SCHEMA_VERSION;
2012
+ result.config ??= defaultConfigMetadata();
2013
+ result.profile ??= DEFAULT_PROFILE;
2014
+ result.capabilityProbes ??= true;
2015
+ result.operations ??= result.operation ? [{
2016
+ index: 0,
2017
+ source: result.operation.source ?? 'request',
2018
+ method: result.operation.method,
2019
+ safeToolCallName: result.operation.safeToolCallName ?? '',
2020
+ responded: Boolean(result.operation.responded),
2021
+ error: result.operation.error ?? null
2022
+ }] : [];
1174
2023
  result.staticScan ??= defaultStaticScan();
1175
2024
  result.staticFindings ??= [];
1176
2025
  result.issues = normalizeIssues(result.issues ?? []);
@@ -1216,6 +2065,12 @@ function buildChecks(result) {
1216
2065
  operation: repeated
1217
2066
  ? aggregateRunCheck(result, 'operation')
1218
2067
  : buildOperationCheck(result, issues),
2068
+ capabilities: repeated
2069
+ ? aggregateCapabilityChecks(result)
2070
+ : buildCapabilityChecks(result, issues),
2071
+ toolSchema: repeated
2072
+ ? aggregateRunCheck(result, 'toolSchema')
2073
+ : buildToolSchemaCheck(result, issues),
1219
2074
  process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
1220
2075
  pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
1221
2076
  staticScan: buildStaticScanCheck(result, issues),
@@ -1241,7 +2096,13 @@ function buildInitializeCheck(result, issues) {
1241
2096
  }
1242
2097
 
1243
2098
  function buildOperationCheck(result, issues) {
1244
- if (!result.operation) {
2099
+ const operations = result.operations?.length
2100
+ ? result.operations
2101
+ : result.operation
2102
+ ? [result.operation]
2103
+ : [];
2104
+
2105
+ if (!operations.length) {
1245
2106
  return makeCheck('skipped', []);
1246
2107
  }
1247
2108
 
@@ -1251,14 +2112,108 @@ function buildOperationCheck(result, issues) {
1251
2112
 
1252
2113
  const matched = issues.filter((issue) => (
1253
2114
  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))
2115
+ || (operations.some((operation) => !operation.responded) && STDOUT_ISSUE_CODES.has(issue.code))
2116
+ || (operations.some((operation) => !operation.responded) && JSON_RPC_ISSUE_CODES.has(issue.code))
1256
2117
  ));
1257
2118
  if (matched.length) {
1258
2119
  return makeCheck(statusFromIssues(matched), matched);
1259
2120
  }
1260
2121
 
1261
- return makeCheck(result.operation.responded ? 'pass' : 'fail', []);
2122
+ return makeCheck(operations.every((operation) => operation.responded) ? 'pass' : 'fail', []);
2123
+ }
2124
+
2125
+ function buildToolSchemaCheck(result, issues) {
2126
+ if (!result.toolSchema?.checked) {
2127
+ return makeCheck('skipped', []);
2128
+ }
2129
+
2130
+ const matched = issues.filter((issue) => TOOL_SCHEMA_ISSUE_CODES.has(issue.code));
2131
+ if (matched.length) {
2132
+ return makeCheck(statusFromIssues(matched), matched);
2133
+ }
2134
+
2135
+ return makeCheck('pass', []);
2136
+ }
2137
+
2138
+ function buildCapabilityChecks(result, issues) {
2139
+ const checks = {};
2140
+ for (const definition of CAPABILITY_DEFINITIONS) {
2141
+ const state = result.capabilityChecks?.[definition.name] ?? defaultCapabilityChecks()[definition.name];
2142
+ const matched = issues.filter((issue) => (
2143
+ CAPABILITY_ISSUE_CODES.has(issue.code)
2144
+ && issue.capability === definition.name
2145
+ ));
2146
+ if (!result.initialized || !state.advertised || result.capabilityProbes === false) {
2147
+ checks[definition.name] = makeCapabilityCheck('skipped', matched, state);
2148
+ } else if (matched.length) {
2149
+ checks[definition.name] = makeCapabilityCheck(statusFromIssues(matched), matched, state);
2150
+ } else {
2151
+ checks[definition.name] = makeCapabilityCheck(state.responded ? 'pass' : 'fail', matched, state);
2152
+ }
2153
+ }
2154
+
2155
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2156
+ const status = active.length
2157
+ ? active.some((check) => check.status === 'fail')
2158
+ ? 'fail'
2159
+ : active.some((check) => check.status === 'warning')
2160
+ ? 'warning'
2161
+ : 'pass'
2162
+ : 'skipped';
2163
+ return {
2164
+ status,
2165
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2166
+ ...checks
2167
+ };
2168
+ }
2169
+
2170
+ function aggregateCapabilityChecks(result) {
2171
+ const checks = {};
2172
+ for (const definition of CAPABILITY_DEFINITIONS) {
2173
+ const runChecks = result.runs
2174
+ .map((run) => run.checks?.capabilities?.[definition.name])
2175
+ .filter(Boolean);
2176
+ const activeRunChecks = runChecks.filter((check) => check.status !== 'skipped');
2177
+ const state = aggregateCapabilityState(definition, runChecks);
2178
+ if (!activeRunChecks.length) {
2179
+ checks[definition.name] = makeAggregatedCapabilityCheck('skipped', [], state);
2180
+ } else {
2181
+ const status = activeRunChecks.some((check) => check.status === 'fail')
2182
+ ? 'fail'
2183
+ : activeRunChecks.some((check) => check.status === 'warning')
2184
+ ? 'warning'
2185
+ : 'pass';
2186
+ checks[definition.name] = makeAggregatedCapabilityCheck(status, activeRunChecks, state);
2187
+ }
2188
+ }
2189
+
2190
+ const active = Object.values(checks).filter((check) => check.status !== 'skipped');
2191
+ const status = active.length
2192
+ ? active.some((check) => check.status === 'fail')
2193
+ ? 'fail'
2194
+ : active.some((check) => check.status === 'warning')
2195
+ ? 'warning'
2196
+ : 'pass'
2197
+ : 'skipped';
2198
+ return {
2199
+ status,
2200
+ issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
2201
+ ...checks
2202
+ };
2203
+ }
2204
+
2205
+ function aggregateCapabilityState(definition, runChecks) {
2206
+ const itemCounts = [...new Set(
2207
+ runChecks
2208
+ .map((check) => check.itemCount)
2209
+ .filter((itemCount) => Number.isInteger(itemCount))
2210
+ )];
2211
+ return {
2212
+ advertised: runChecks.some((check) => check.advertised),
2213
+ method: definition.method,
2214
+ responded: runChecks.some((check) => check.responded),
2215
+ itemCount: itemCounts.length === 1 ? itemCounts[0] : null
2216
+ };
1262
2217
  }
1263
2218
 
1264
2219
  function buildStaticScanCheck(result, issues) {
@@ -1284,8 +2239,16 @@ function buildRepeatCheck(result) {
1284
2239
  }
1285
2240
 
1286
2241
  const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
2242
+ const repeatIssues = (result.issues ?? []).filter((issue) => (
2243
+ issue.run || REPEAT_DRIFT_ISSUE_CODES.has(issue.code)
2244
+ ));
2245
+ const status = failedRuns.length
2246
+ ? 'fail'
2247
+ : repeatIssues.some((issue) => issue.severity === 'warning')
2248
+ ? 'warning'
2249
+ : 'pass';
1287
2250
  return {
1288
- ...makeCheck(failedRuns.length ? 'fail' : 'pass', result.issues ?? []),
2251
+ ...makeCheck(status, repeatIssues),
1289
2252
  runs: result.runs.length,
1290
2253
  passedRuns: result.runs.length - failedRuns.length,
1291
2254
  failedRuns
@@ -1327,6 +2290,42 @@ function makeCheck(status, issues) {
1327
2290
  };
1328
2291
  }
1329
2292
 
2293
+ function makeCapabilityCheck(status, issues, state) {
2294
+ return {
2295
+ ...makeCheck(status, issues),
2296
+ advertised: Boolean(state.advertised),
2297
+ method: state.method,
2298
+ responded: Boolean(state.responded),
2299
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2300
+ };
2301
+ }
2302
+
2303
+ function makeAggregatedCapabilityCheck(status, checks, state) {
2304
+ return {
2305
+ status,
2306
+ issueCodes: [...new Set(checks.flatMap((check) => check.issueCodes ?? []))].sort(),
2307
+ advertised: Boolean(state.advertised),
2308
+ method: state.method,
2309
+ responded: Boolean(state.responded),
2310
+ itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
2311
+ };
2312
+ }
2313
+
2314
+ function defaultConfigMetadata() {
2315
+ return {
2316
+ enabled: false,
2317
+ path: '',
2318
+ resolvedPath: '',
2319
+ checks: {
2320
+ command: false,
2321
+ cwd: false,
2322
+ envNames: [],
2323
+ requests: [],
2324
+ safeToolCalls: []
2325
+ }
2326
+ };
2327
+ }
2328
+
1330
2329
  function defaultStaticScan() {
1331
2330
  return {
1332
2331
  enabled: false,
@@ -1335,6 +2334,253 @@ function defaultStaticScan() {
1335
2334
  };
1336
2335
  }
1337
2336
 
2337
+ function defaultToolSchemaValidation() {
2338
+ return {
2339
+ checked: false,
2340
+ toolCount: 0,
2341
+ toolNames: [],
2342
+ validToolCount: 0,
2343
+ warningCount: 0,
2344
+ errorCount: 0,
2345
+ duplicateNames: []
2346
+ };
2347
+ }
2348
+
2349
+ function aggregateRunToolSchemaValidation(runs) {
2350
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
2351
+ if (!checkedRuns.length) {
2352
+ return defaultToolSchemaValidation();
2353
+ }
2354
+
2355
+ return {
2356
+ checked: true,
2357
+ runs: checkedRuns.length,
2358
+ toolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.toolCount, 0),
2359
+ validToolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.validToolCount, 0),
2360
+ warningCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.warningCount, 0),
2361
+ errorCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.errorCount, 0),
2362
+ toolNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.toolNames ?? []))].sort(),
2363
+ duplicateNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.duplicateNames))].sort()
2364
+ };
2365
+ }
2366
+
2367
+ function defaultRepeatDrift() {
2368
+ return {
2369
+ checked: false,
2370
+ status: 'skipped',
2371
+ issueCodes: [],
2372
+ baselineRun: null,
2373
+ comparedRuns: [],
2374
+ negotiatedProtocol: driftSection(),
2375
+ capabilities: driftSection(),
2376
+ tools: driftSection(),
2377
+ lists: {
2378
+ resources: driftSection(),
2379
+ prompts: driftSection()
2380
+ }
2381
+ };
2382
+ }
2383
+
2384
+ function driftSection() {
2385
+ return {
2386
+ status: 'skipped',
2387
+ issueCodes: [],
2388
+ baseline: null,
2389
+ values: [],
2390
+ changedRuns: []
2391
+ };
2392
+ }
2393
+
2394
+ function buildRepeatDrift(runs) {
2395
+ const drift = defaultRepeatDrift();
2396
+ if (!Array.isArray(runs) || runs.length < 2) {
2397
+ return drift;
2398
+ }
2399
+
2400
+ drift.checked = true;
2401
+ const comparableRuns = runs.filter((run) => run.initialized);
2402
+ drift.comparedRuns = comparableRuns.map((run) => run.run);
2403
+ if (comparableRuns.length < 2) {
2404
+ return drift;
2405
+ }
2406
+
2407
+ drift.baselineRun = comparableRuns[0].run;
2408
+ drift.negotiatedProtocol = compareScalarDrift(
2409
+ comparableRuns,
2410
+ (run) => run.negotiatedProtocol || '',
2411
+ 'repeat-protocol-drift'
2412
+ );
2413
+ drift.capabilities = compareSetDrift(
2414
+ comparableRuns,
2415
+ (run) => run.capabilityKeys ?? advertisedCapabilityKeys(run.capabilityChecks),
2416
+ 'repeat-capability-drift'
2417
+ );
2418
+ drift.tools = compareToolDrift(comparableRuns);
2419
+ drift.lists.resources = compareListShapeDrift(comparableRuns, 'resources');
2420
+ drift.lists.prompts = compareListShapeDrift(comparableRuns, 'prompts');
2421
+
2422
+ const sections = [
2423
+ drift.negotiatedProtocol,
2424
+ drift.capabilities,
2425
+ drift.tools,
2426
+ drift.lists.resources,
2427
+ drift.lists.prompts
2428
+ ];
2429
+ const active = sections.filter((section) => section.status !== 'skipped');
2430
+ drift.status = active.length
2431
+ ? active.some((section) => section.status === 'warning')
2432
+ ? 'warning'
2433
+ : 'pass'
2434
+ : 'skipped';
2435
+ drift.issueCodes = [...new Set(active.flatMap((section) => section.issueCodes))].sort();
2436
+ return drift;
2437
+ }
2438
+
2439
+ function compareScalarDrift(runs, valueForRun, issueCode) {
2440
+ const section = driftSection();
2441
+ section.values = runs.map((run) => ({
2442
+ run: run.run,
2443
+ value: valueForRun(run)
2444
+ }));
2445
+ section.baseline = section.values[0].value;
2446
+ section.changedRuns = section.values
2447
+ .slice(1)
2448
+ .filter((entry) => entry.value !== section.baseline)
2449
+ .map((entry) => ({
2450
+ run: entry.run,
2451
+ expected: section.baseline,
2452
+ actual: entry.value
2453
+ }));
2454
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
2455
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
2456
+ return section;
2457
+ }
2458
+
2459
+ function compareSetDrift(runs, valuesForRun, issueCode) {
2460
+ const section = driftSection();
2461
+ section.values = runs.map((run) => ({
2462
+ run: run.run,
2463
+ values: sortedUnique(valuesForRun(run))
2464
+ }));
2465
+ section.baseline = section.values[0].values;
2466
+ section.changedRuns = section.values
2467
+ .slice(1)
2468
+ .map((entry) => ({
2469
+ run: entry.run,
2470
+ added: arrayDifference(entry.values, section.baseline),
2471
+ removed: arrayDifference(section.baseline, entry.values)
2472
+ }))
2473
+ .filter((entry) => entry.added.length || entry.removed.length);
2474
+ section.status = section.changedRuns.length ? 'warning' : 'pass';
2475
+ section.issueCodes = section.changedRuns.length ? [issueCode] : [];
2476
+ return section;
2477
+ }
2478
+
2479
+ function compareToolDrift(runs) {
2480
+ const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
2481
+ if (checkedRuns.length < 2) {
2482
+ return driftSection();
2483
+ }
2484
+
2485
+ const section = compareSetDrift(
2486
+ checkedRuns,
2487
+ (run) => run.toolSchema.toolNames ?? [],
2488
+ 'repeat-tool-drift'
2489
+ );
2490
+ section.values = checkedRuns.map((run) => ({
2491
+ run: run.run,
2492
+ count: run.toolSchema.toolCount,
2493
+ values: sortedUnique(run.toolSchema.toolNames ?? [])
2494
+ }));
2495
+ section.baselineCount = section.values[0].count;
2496
+ const countDrift = section.values
2497
+ .slice(1)
2498
+ .filter((entry) => entry.count !== section.baselineCount)
2499
+ .map((entry) => ({
2500
+ run: entry.run,
2501
+ expected: section.baselineCount,
2502
+ actual: entry.count
2503
+ }));
2504
+ if (countDrift.length) {
2505
+ section.countChangedRuns = countDrift;
2506
+ section.status = 'warning';
2507
+ section.issueCodes = ['repeat-tool-drift'];
2508
+ }
2509
+ return section;
2510
+ }
2511
+
2512
+ function compareListShapeDrift(runs, capability) {
2513
+ const checkedRuns = runs.filter((run) => Number.isInteger(run.capabilityChecks?.[capability]?.itemCount));
2514
+ if (checkedRuns.length < 2) {
2515
+ return driftSection();
2516
+ }
2517
+
2518
+ const section = compareScalarDrift(
2519
+ checkedRuns,
2520
+ (run) => run.capabilityChecks[capability].itemCount,
2521
+ 'repeat-list-shape-drift'
2522
+ );
2523
+ section.capability = capability;
2524
+ return section;
2525
+ }
2526
+
2527
+ function repeatDriftIssues(drift) {
2528
+ if (!drift?.checked || drift.status !== 'warning') {
2529
+ return [];
2530
+ }
2531
+
2532
+ const issues = [];
2533
+ if (drift.negotiatedProtocol.status === 'warning') {
2534
+ issues.push(repeatDriftIssue('repeat-protocol-drift', 'negotiated protocol changed across repeat runs', {
2535
+ drift: drift.negotiatedProtocol
2536
+ }));
2537
+ }
2538
+ if (drift.capabilities.status === 'warning') {
2539
+ issues.push(repeatDriftIssue('repeat-capability-drift', 'advertised capability keys changed across repeat runs', {
2540
+ drift: drift.capabilities
2541
+ }));
2542
+ }
2543
+ if (drift.tools.status === 'warning') {
2544
+ issues.push(repeatDriftIssue('repeat-tool-drift', 'tools/list tool names or counts changed across repeat runs', {
2545
+ drift: drift.tools
2546
+ }));
2547
+ }
2548
+ for (const capability of ['resources', 'prompts']) {
2549
+ if (drift.lists[capability].status === 'warning') {
2550
+ issues.push(repeatDriftIssue('repeat-list-shape-drift', `${capability}/list item count changed across repeat runs`, {
2551
+ capability,
2552
+ drift: drift.lists[capability]
2553
+ }));
2554
+ }
2555
+ }
2556
+ return issues;
2557
+ }
2558
+
2559
+ function repeatDriftIssue(code, message, details) {
2560
+ return {
2561
+ severity: 'warning',
2562
+ code,
2563
+ message,
2564
+ ...details
2565
+ };
2566
+ }
2567
+
2568
+ function advertisedCapabilityKeys(capabilityChecks = {}) {
2569
+ return Object.entries(capabilityChecks)
2570
+ .filter(([, check]) => check?.advertised)
2571
+ .map(([name]) => name)
2572
+ .sort();
2573
+ }
2574
+
2575
+ function sortedUnique(values) {
2576
+ return [...new Set((values ?? []).filter((value) => typeof value === 'string'))].sort();
2577
+ }
2578
+
2579
+ function arrayDifference(left, right) {
2580
+ const rightSet = new Set(right);
2581
+ return left.filter((value) => !rightSet.has(value));
2582
+ }
2583
+
1338
2584
  export function scanSource(root) {
1339
2585
  const findings = [];
1340
2586
  const absoluteRoot = path.resolve(root);
@@ -1344,12 +2590,14 @@ export function scanSource(root) {
1344
2590
  const text = fs.readFileSync(file, 'utf8');
1345
2591
  const lines = text.split(/\r?\n/);
1346
2592
  for (let index = 0; index < lines.length; index += 1) {
1347
- const message = detectStdoutWrite(file, lines[index]);
1348
- if (message) {
2593
+ const finding = detectStdoutRisk(file, lines[index]);
2594
+ if (finding) {
1349
2595
  findings.push({
1350
2596
  file: path.relative(process.cwd(), file).split(path.sep).join('/'),
1351
2597
  line: index + 1,
1352
- message
2598
+ language: finding.language,
2599
+ reason: finding.reason,
2600
+ message: finding.message
1353
2601
  });
1354
2602
  }
1355
2603
  }
@@ -1358,34 +2606,254 @@ export function scanSource(root) {
1358
2606
  return findings;
1359
2607
  }
1360
2608
 
1361
- function detectStdoutWrite(file, line) {
1362
- const ext = path.extname(file);
2609
+ function detectStdoutRisk(file, line) {
2610
+ const ext = path.extname(file).toLowerCase();
1363
2611
  const stripped = line.trim();
1364
2612
  if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
2613
+ const code = normalizeSourceLine(line, ext);
2614
+
2615
+ if (isJavaScriptSource(ext)) {
2616
+ if (/\bconsole\.(log|info)\s*\(/.test(code)) {
2617
+ return staticFinding(
2618
+ 'javascript',
2619
+ 'javascript-console-stdout',
2620
+ 'console.log/info writes to stdout; use console.error for MCP stdio diagnostics'
2621
+ );
2622
+ }
2623
+
2624
+ if (/\bprocess\.stdout\.write\s*\(/.test(code)) {
2625
+ return staticFinding(
2626
+ 'javascript',
2627
+ 'javascript-process-stdout-write',
2628
+ 'direct process.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
2629
+ );
2630
+ }
2631
+
2632
+ if (/\bprocess\.stdout\.(clearLine|cursorTo|moveCursor)\s*\(/.test(code)) {
2633
+ return staticFinding(
2634
+ 'javascript',
2635
+ 'javascript-stdout-terminal-control',
2636
+ 'process.stdout terminal control writes to stdout; keep MCP stdout reserved for JSON-RPC frames'
2637
+ );
2638
+ }
2639
+
2640
+ if (/\bdotenv\.config\s*\(\s*\{[^}]*\bdebug\s*:\s*true\b/.test(code)) {
2641
+ return staticFinding(
2642
+ 'javascript',
2643
+ 'javascript-dotenv-debug-output',
2644
+ 'dotenv debug output can print during startup; keep MCP diagnostics on stderr'
2645
+ );
2646
+ }
1365
2647
 
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';
2648
+ if (/\b(stream|file)\s*:\s*process\.stdout\b/.test(code)) {
2649
+ return staticFinding(
2650
+ 'javascript',
2651
+ 'javascript-progress-stdout',
2652
+ 'progress or spinner output is configured for stdout; MCP stdout must stay JSON-RPC only'
2653
+ );
2654
+ }
1368
2655
  }
1369
2656
 
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';
2657
+ if (ext === '.py') {
2658
+ if (/(^|[^\w.])print\s*\(/.test(code) && !/file\s*=\s*sys\.stderr/.test(code)) {
2659
+ return staticFinding(
2660
+ 'python',
2661
+ 'python-print-stdout',
2662
+ 'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics'
2663
+ );
2664
+ }
2665
+
2666
+ if (/\bsys\.stdout\.write\s*\(/.test(code)) {
2667
+ return staticFinding(
2668
+ 'python',
2669
+ 'python-sys-stdout-write',
2670
+ 'direct sys.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
2671
+ );
2672
+ }
2673
+
2674
+ if (/\blogging\.StreamHandler\s*\(\s*sys\.stdout\s*\)/.test(code)
2675
+ || /\blogging\.basicConfig\s*\([^)]*\bstream\s*=\s*sys\.stdout\b/.test(code)) {
2676
+ return staticFinding(
2677
+ 'python',
2678
+ 'python-logging-stdout-handler',
2679
+ 'Python logging is configured to write to stdout; route MCP diagnostics to stderr'
2680
+ );
2681
+ }
2682
+
2683
+ if (/\btqdm\s*\([^)]*\bfile\s*=\s*sys\.stdout\b/.test(code)) {
2684
+ return staticFinding(
2685
+ 'python',
2686
+ 'python-progress-stdout',
2687
+ 'progress output is configured for stdout; MCP stdout must stay JSON-RPC only'
2688
+ );
2689
+ }
1372
2690
  }
1373
2691
 
1374
- if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(line)) {
1375
- return 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics';
2692
+ if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(code)) {
2693
+ return staticFinding(
2694
+ 'go',
2695
+ 'go-fmt-stdout',
2696
+ 'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics'
2697
+ );
1376
2698
  }
1377
2699
 
1378
- if (ext === '.rs' && /\bprintln!\s*\(/.test(line)) {
1379
- return 'println! writes to stdout; use eprintln! for MCP stdio diagnostics';
2700
+ if (ext === '.rs' && /\bprintln!\s*\(/.test(code)) {
2701
+ return staticFinding(
2702
+ 'rust',
2703
+ 'rust-println-stdout',
2704
+ 'println! writes to stdout; use eprintln! for MCP stdio diagnostics'
2705
+ );
1380
2706
  }
1381
2707
 
1382
- if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(line)) {
1383
- return 'System.out writes to stdout; use stderr for MCP stdio diagnostics';
2708
+ if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(code)) {
2709
+ return staticFinding(
2710
+ 'jvm',
2711
+ 'jvm-system-out',
2712
+ 'System.out writes to stdout; use stderr for MCP stdio diagnostics'
2713
+ );
1384
2714
  }
1385
2715
 
1386
2716
  return '';
1387
2717
  }
1388
2718
 
2719
+ function staticFinding(language, reason, message) {
2720
+ return { language, reason, message };
2721
+ }
2722
+
2723
+ function isJavaScriptSource(ext) {
2724
+ return ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext);
2725
+ }
2726
+
2727
+ function normalizeSourceLine(line, ext) {
2728
+ return stripInlineComment(maskStringLiterals(line), ext);
2729
+ }
2730
+
2731
+ function stripInlineComment(line, ext) {
2732
+ if (ext === '.py') {
2733
+ return line.split('#')[0];
2734
+ }
2735
+
2736
+ if (isJavaScriptSource(ext) || ['.go', '.rs', '.java', '.kt'].includes(ext)) {
2737
+ return stripJavaScriptLikeInlineComment(line);
2738
+ }
2739
+
2740
+ return line;
2741
+ }
2742
+
2743
+ function stripJavaScriptLikeInlineComment(line) {
2744
+ let inRegex = false;
2745
+ let escaped = false;
2746
+ let inCharClass = false;
2747
+
2748
+ for (let index = 0; index < line.length; index += 1) {
2749
+ const char = line[index];
2750
+ const next = line[index + 1];
2751
+
2752
+ if (inRegex) {
2753
+ if (escaped) {
2754
+ escaped = false;
2755
+ } else if (char === '\\') {
2756
+ escaped = true;
2757
+ } else if (char === '[') {
2758
+ inCharClass = true;
2759
+ } else if (char === ']') {
2760
+ inCharClass = false;
2761
+ } else if (char === '/' && !inCharClass) {
2762
+ inRegex = false;
2763
+ }
2764
+ continue;
2765
+ }
2766
+
2767
+ if (char === '/' && next === '/') {
2768
+ return line.slice(0, index);
2769
+ }
2770
+
2771
+ if (char === '/' && next === '*') {
2772
+ return line.slice(0, index);
2773
+ }
2774
+
2775
+ if (char === '/' && canStartJavaScriptRegex(line, index)) {
2776
+ inRegex = true;
2777
+ }
2778
+ }
2779
+
2780
+ return line;
2781
+ }
2782
+
2783
+ function canStartJavaScriptRegex(line, index) {
2784
+ const before = line.slice(0, index).trimEnd();
2785
+ if (!before) return true;
2786
+
2787
+ const last = before[before.length - 1];
2788
+ if ('=(:,[!&|?;{}>'.includes(last)) return true;
2789
+
2790
+ return /\b(await|case|delete|of|return|throw|typeof|void|yield)$/.test(before);
2791
+ }
2792
+
2793
+ function maskStringLiterals(line) {
2794
+ let quote = '';
2795
+ let escaped = false;
2796
+ let masked = '';
2797
+ let templateExpressionDepth = 0;
2798
+
2799
+ for (let index = 0; index < line.length; index += 1) {
2800
+ const char = line[index];
2801
+ const next = line[index + 1];
2802
+
2803
+ if (quote) {
2804
+ if (quote === '`' && char === '$' && next === '{') {
2805
+ quote = '';
2806
+ templateExpressionDepth += 1;
2807
+ masked += ' ';
2808
+ index += 1;
2809
+ continue;
2810
+ }
2811
+
2812
+ if (escaped) {
2813
+ escaped = false;
2814
+ } else if (char === '\\') {
2815
+ escaped = true;
2816
+ } else if (char === quote) {
2817
+ quote = '';
2818
+ }
2819
+ masked += ' ';
2820
+ continue;
2821
+ }
2822
+
2823
+ if (templateExpressionDepth > 0) {
2824
+ if (char === '"' || char === "'" || char === '`') {
2825
+ quote = char;
2826
+ masked += ' ';
2827
+ continue;
2828
+ }
2829
+
2830
+ if (char === '{') {
2831
+ templateExpressionDepth += 1;
2832
+ } else if (char === '}') {
2833
+ templateExpressionDepth -= 1;
2834
+ if (templateExpressionDepth === 0) {
2835
+ quote = '`';
2836
+ masked += ' ';
2837
+ continue;
2838
+ }
2839
+ }
2840
+
2841
+ masked += char;
2842
+ continue;
2843
+ }
2844
+
2845
+ if (char === '"' || char === "'" || char === '`') {
2846
+ quote = char;
2847
+ masked += ' ';
2848
+ continue;
2849
+ }
2850
+
2851
+ masked += char;
2852
+ }
2853
+
2854
+ return masked;
2855
+ }
2856
+
1389
2857
  function listSourceFiles(root) {
1390
2858
  const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
1391
2859
  const files = [];
@@ -1431,6 +2899,10 @@ function formatTextResult(result) {
1431
2899
  lines.push(`request: ${result.operation.method} ${state}`);
1432
2900
  }
1433
2901
 
2902
+ if (result.toolSchema?.checked) {
2903
+ lines.push(`tool schemas: ${result.toolSchema.validToolCount}/${result.toolSchema.toolCount} valid`);
2904
+ }
2905
+
1434
2906
  if (result.staticFindings.length) {
1435
2907
  lines.push(`static findings: ${result.staticFindings.length}`);
1436
2908
  for (const finding of result.staticFindings.slice(0, 10)) {
@@ -1453,11 +2925,19 @@ function formatRepeatedTextResult(result) {
1453
2925
  `runs: ${passedRuns}/${result.runs.length} passed`
1454
2926
  ];
1455
2927
 
2928
+ if (result.drift?.checked && result.drift.status !== 'skipped') {
2929
+ const issueCodes = result.drift.issueCodes.length ? ` (${result.drift.issueCodes.join(', ')})` : '';
2930
+ lines.push(`drift: ${result.drift.status}${issueCodes}`);
2931
+ }
2932
+
1456
2933
  for (const run of result.runs) {
1457
2934
  const runStatus = run.ok ? 'PASS' : 'FAIL';
1458
2935
  const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
1459
2936
  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`);
2937
+ const toolSchemas = run.toolSchema?.checked
2938
+ ? `, tool schemas ${run.toolSchema.validToolCount}/${run.toolSchema.toolCount} valid`
2939
+ : '';
2940
+ lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines${toolSchemas}`);
1461
2941
  }
1462
2942
 
1463
2943
  if (result.staticFindings.length) {
@@ -1505,6 +2985,8 @@ Usage:
1505
2985
  mcp-stdio-guard [options] -- <command> [args...]
1506
2986
 
1507
2987
  Options:
2988
+ --config <path> read JSON config for registry runs and safe tool calls
2989
+ --profile <name> guard profile: custom, smoke, registry, ci, strict
1508
2990
  --protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
1509
2991
  --timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
1510
2992
  --repeat <count> run the guard multiple times, default 1