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