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.
- package/README.md +144 -13
- package/package.json +1 -1
- 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.
|
|
9
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.4.0';
|
|
10
10
|
} catch {
|
|
11
|
-
return '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
|
-
|
|
209
|
+
env: options.env,
|
|
210
|
+
probeCapabilities: options.probeCapabilities,
|
|
211
|
+
operations: options.operations,
|
|
212
|
+
operation: options.operations.length === 1
|
|
149
213
|
? {
|
|
150
|
-
method: options.
|
|
151
|
-
params: options.
|
|
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
|
|
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:
|
|
717
|
+
operation: operations.length === 1
|
|
333
718
|
? {
|
|
334
|
-
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
|
-
|
|
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,
|
|
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
|
-
?
|
|
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 &&
|
|
477
|
-
|
|
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.
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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', '
|
|
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 &&
|
|
1150
|
+
} else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
|
|
591
1151
|
clearTimeout(timer);
|
|
592
|
-
addIssue('error', 'response-id-
|
|
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 &&
|
|
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
|
|
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'
|
|
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
|
|
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
|
-
|
|
1675
|
+
capabilityProbes: options.probeCapabilities ?? true,
|
|
1676
|
+
operation: operations.length === 1
|
|
847
1677
|
? {
|
|
848
|
-
method:
|
|
849
|
-
hasParams:
|
|
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
|
-
|
|
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
|
-
|| (
|
|
1255
|
-
|| (
|
|
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(
|
|
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(
|
|
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
|
|
1348
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1367
|
-
|
|
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'
|
|
1371
|
-
|
|
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(
|
|
1375
|
-
return
|
|
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(
|
|
1379
|
-
return
|
|
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(
|
|
1383
|
-
return
|
|
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
|
-
|
|
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
|