mcp-stdio-guard 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +105 -1
  2. package/package.json +1 -1
  3. package/src/index.js +717 -24
package/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  <p align="center">
12
12
  <a href="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml/badge.svg" /></a>
13
13
  <a href="https://www.npmjs.com/package/mcp-stdio-guard"><img alt="npm" src="https://img.shields.io/npm/v/mcp-stdio-guard?color=0b6bcb" /></a>
14
+ <a href="https://badge.socket.dev/npm/package/mcp-stdio-guard/0.3.0"><img alt="Socket" src="https://badge.socket.dev/npm/package/mcp-stdio-guard/0.3.0" /></a>
14
15
  <img alt="runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-0-1f8f4c" />
15
16
  <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-2f855a" />
16
17
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-111827" /></a>
@@ -145,20 +146,123 @@ mcp-stdio-guard [options] -- <command> [args...]
145
146
  | `negotiatedProtocol` | protocol version returned by the server, when available |
146
147
  | `initialized` | whether the server completed the initialize handshake |
147
148
  | `operation` | post-initialize request result, or `null` when `--request` was not used |
149
+ | `process` | startup, timeout, exit code, signal, and guard-termination metadata for a single run; repeat mode exposes this inside each `runs` entry |
148
150
  | `checks` | badge-friendly per-class statuses |
149
- | `issues` | machine-readable diagnostics with `severity`, `code`, and `message`; repeat mode also adds `run` |
151
+ | `issueClasses` | registry-friendly summary grouped by `installRuntime`, `stdioTransport`, and `mcpProtocol` |
152
+ | `fingerprint` | redacted reproducibility metadata for debugging registry and CI runs |
153
+ | `issues` | machine-readable diagnostics with `class`, `severity`, `code`, and `message`; repeat mode also adds `run` |
150
154
  | `staticScan` | whether source scanning was enabled and whether findings fail the command |
151
155
  | `staticFindings` | source scan findings with file, line, and message |
152
156
  | `runs` | per-run results when `--repeat` is used |
153
157
 
154
158
  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
159
 
160
+ `issueClasses` is additive to `checks`. It groups issue codes by the kind of problem a registry or client should display:
161
+
162
+ | Issue class | Meaning | Display guidance |
163
+ | --- | --- | --- |
164
+ | `installRuntime` | the command could not start, timed out, exited, crashed, or hit a runtime advisory | show as "needs inspection" or "runtime/install issue"; do not present it as an MCP protocol violation |
165
+ | `stdioTransport` | stdout was not a clean newline-delimited JSON-RPC channel, or source scan found risky stdout writes | show as stdio hygiene failure; ask maintainers to keep diagnostics on stderr |
166
+ | `mcpProtocol` | the server emitted invalid JSON-RPC/MCP responses, mismatched request ids, or returned initialize/operation errors | show as MCP/JSON-RPC conformance issue |
167
+
168
+ Current issue-code mapping:
169
+
170
+ | Issue class | Issue codes |
171
+ | --- | --- |
172
+ | `installRuntime` | `initialize-timeout`, `operation-missing-response`, `operation-timeout`, `python-buffered-stdio`, `server-crashed`, `server-exited`, `spawn-failed` |
173
+ | `stdioTransport` | `static-stdout-write`, `stdout-content-length-framing`, `stdout-empty-line`, `stdout-non-json`, `stdout-without-newline` |
174
+ | `mcpProtocol` | `initialize-error`, `initialize-invalid-capabilities`, `initialize-invalid-protocol-version`, `initialize-invalid-result`, `initialize-invalid-server-info`, `initialize-missing-capabilities`, `initialize-missing-protocol-version`, `initialize-missing-server-info`, `notification-response`, `operation-error`, `response-id-mismatch`, `response-id-type-mismatch`, `stdout-invalid-json-rpc`, `stdout-unexpected-request-id` |
175
+
176
+ Initialize lifecycle checks are part of the MCP protocol class. Missing or invalid `protocolVersion` and `capabilities` fail the run before the guard sends `notifications/initialized` or any normal request. Missing or invalid `serverInfo` is warning-level so registries can surface incomplete metadata without confusing it with a broken transport.
177
+
178
+ JSON-RPC invariant checks distinguish wrong response ids from id type round-trip problems and fail servers that respond to `notifications/initialized`. JSON-RPC error frames must be structured with numeric `code` and string `message` fields.
179
+
180
+ Runtime issue codes remain backward-compatible. For finer registry display, runtime issues may also include a stable `detailCode`:
181
+
182
+ | Existing issue code | Detail codes |
183
+ | --- | --- |
184
+ | `spawn-failed` | `spawn-failed-before-startup` |
185
+ | `server-exited` | `clean-exit-before-initialize`, `nonzero-exit-before-initialize`, `signal-exit-before-initialize` |
186
+ | `initialize-timeout` | `startup-timeout` |
187
+ | `operation-timeout` | `request-timeout` |
188
+ | `operation-missing-response` | `clean-exit-during-operation`, `nonzero-exit-during-operation`, `signal-exit-during-operation` |
189
+ | `server-crashed` | `nonzero-exit-after-initialize`, `signal-exit-after-initialize` |
190
+
191
+ `process` records the observed lifecycle even when the run passes. `outcome` is one of `starting`, `running`, `exited`, `timeout`, `spawn-failed`, or `guard-terminated`; `starting` is the transient initial value while the child is being created, not an expected terminal outcome. `phase` is `startup`, `initialize`, `operation`, or `post-initialize`. `exitCode` and `signal` are included when the process exits before the guard finishes; timeout runs include `timedOut`, `timeoutCode`, `timeoutMs`, and guard kill metadata. `spawnError` is either `null` or an object with `code` and `message`; the matching `spawn-failed` issue also exposes `spawnErrorCode`.
192
+
193
+ Spawn failure shape:
194
+
195
+ | Field | Shape |
196
+ | --- | --- |
197
+ | `process.spawnError` | `null` or `{ "code": "ENOENT", "message": "spawn missing-command ENOENT" }` |
198
+ | `issues[].spawnErrorCode` | short platform error code such as `ENOENT`, or `""` when unavailable |
199
+
200
+ `fingerprint` helps explain why a result reproduced in one runner but not another. It includes the guard version, redacted command argv, cwd details, protocol, timeout, repeat count, requested operation, platform/arch, relevant runtime versions, package metadata when detectable, static-scan context, and startup/total duration. Environment variable values are always emitted as `<redacted>` and only explicitly provided env names are listed.
201
+
202
+ Registry display flow:
203
+
204
+ | Step | Use |
205
+ | --- | --- |
206
+ | 1 | Show `issueClasses` first so install/runtime, stdio transport, and MCP protocol failures stay distinct |
207
+ | 2 | Use `fingerprint.command`, `fingerprint.cwd`, and `fingerprint.package` to show what was actually run |
208
+ | 3 | Compare `fingerprint.system`, `fingerprint.runtimes`, and `fingerprint.timings` before marking a package broken |
209
+ | 4 | Show `fingerprint.env.names` only when debugging; never ask users to paste secret values |
210
+
156
211
  Example:
157
212
 
158
213
  ```json
159
214
  {
160
215
  "schemaVersion": 1,
161
216
  "ok": true,
217
+ "fingerprint": {
218
+ "guard": { "name": "mcp-stdio-guard", "version": "0.3.0" },
219
+ "command": {
220
+ "executable": "node",
221
+ "args": ["./server.js"],
222
+ "argv": ["node", "./server.js"]
223
+ },
224
+ "cwd": {
225
+ "requested": "/repo/server",
226
+ "resolved": "/repo/server",
227
+ "exists": true
228
+ },
229
+ "protocol": "2025-11-25",
230
+ "timeoutMs": 5000,
231
+ "repeat": 1,
232
+ "operation": { "method": "tools/list", "hasParams": false },
233
+ "system": { "platform": "darwin", "arch": "arm64", "osRelease": "25.0.0" },
234
+ "runtimes": {
235
+ "node": { "version": "v24.0.0", "role": "guard-and-target" }
236
+ },
237
+ "package": null,
238
+ "env": {
239
+ "inherited": true,
240
+ "names": ["API_TOKEN"],
241
+ "values": { "API_TOKEN": "<redacted>" }
242
+ },
243
+ "staticScan": { "enabled": false, "path": "", "failOnFindings": false },
244
+ "timings": { "startupMs": 42, "totalMs": 96 }
245
+ },
246
+ "process": {
247
+ "started": true,
248
+ "pid": 12345,
249
+ "outcome": "guard-terminated",
250
+ "phase": "post-initialize",
251
+ "exitCode": null,
252
+ "signal": null,
253
+ "timedOut": false,
254
+ "timeoutCode": "",
255
+ "timeoutMs": 5000,
256
+ "killedByGuard": true,
257
+ "killSignal": "SIGTERM",
258
+ "killReason": "guard-finished",
259
+ "spawnError": null
260
+ },
261
+ "issueClasses": {
262
+ "installRuntime": { "status": "pass", "issueCodes": [] },
263
+ "stdioTransport": { "status": "pass", "issueCodes": [] },
264
+ "mcpProtocol": { "status": "pass", "issueCodes": [] }
265
+ },
162
266
  "checks": {
163
267
  "initialize": { "status": "pass", "issueCodes": [] },
164
268
  "stdout": { "status": "pass", "issueCodes": [] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-stdio-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A runtime zero-dependency CLI that catches stdout pollution and handshake failures in MCP stdio servers.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
+ import { spawn, spawnSync } from 'node:child_process';
4
5
 
5
6
  function loadVersion() {
6
7
  try {
7
8
  const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
- return typeof packageJson.version === 'string' ? packageJson.version : '0.2.0';
9
+ return typeof packageJson.version === 'string' ? packageJson.version : '0.3.0';
9
10
  } catch {
10
- return '0.2.0';
11
+ return '0.3.0';
11
12
  }
12
13
  }
13
14
 
@@ -15,6 +16,44 @@ const DEFAULT_PROTOCOL = '2025-11-25';
15
16
  const DEFAULT_TIMEOUT = 5000;
16
17
  const VERSION = loadVersion();
17
18
  const JSON_SCHEMA_VERSION = 1;
19
+ const REDACTED = '<redacted>';
20
+ const VERSION_PROBE_CACHE = new Map();
21
+ const NODE_OPTIONS_WITH_VALUES = new Set([
22
+ '--conditions',
23
+ '--cpu-prof-dir',
24
+ '--diagnostic-dir',
25
+ '--experimental-loader',
26
+ '--heapsnapshot-near-heap-limit',
27
+ '--import',
28
+ '--inspect-port',
29
+ '--loader',
30
+ '--max-old-space-size',
31
+ '--openssl-config',
32
+ '--perf-basic-prof-only-functions',
33
+ '--prof-process',
34
+ '--redirect-warnings',
35
+ '--require',
36
+ '--test-reporter',
37
+ '--test-reporter-destination',
38
+ '--title',
39
+ '--trace-event-categories',
40
+ '--trace-event-file-pattern',
41
+ '-C',
42
+ '-r'
43
+ ]);
44
+ const NODE_EVAL_OPTIONS = new Set(['--eval', '--print', '-e', '-p']);
45
+
46
+ export const ISSUE_CLASSES = Object.freeze({
47
+ INSTALL_RUNTIME: 'installRuntime',
48
+ STDIO_TRANSPORT: 'stdioTransport',
49
+ MCP_PROTOCOL: 'mcpProtocol'
50
+ });
51
+
52
+ const ISSUE_CLASS_NAMES = [
53
+ ISSUE_CLASSES.INSTALL_RUNTIME,
54
+ ISSUE_CLASSES.STDIO_TRANSPORT,
55
+ ISSUE_CLASSES.MCP_PROTOCOL
56
+ ];
18
57
 
19
58
  const STDOUT_ISSUE_CODES = new Set([
20
59
  'stdout-empty-line',
@@ -25,12 +64,21 @@ const STDOUT_ISSUE_CODES = new Set([
25
64
  'stdout-without-newline'
26
65
  ]);
27
66
  const JSON_RPC_ISSUE_CODES = new Set([
67
+ 'notification-response',
68
+ 'response-id-mismatch',
28
69
  'response-id-type-mismatch',
29
70
  'stdout-invalid-json-rpc',
30
71
  'stdout-unexpected-request-id'
31
72
  ]);
32
73
  const INITIALIZE_ISSUE_CODES = new Set([
33
74
  'initialize-error',
75
+ 'initialize-invalid-capabilities',
76
+ 'initialize-invalid-protocol-version',
77
+ 'initialize-invalid-result',
78
+ 'initialize-invalid-server-info',
79
+ 'initialize-missing-capabilities',
80
+ 'initialize-missing-protocol-version',
81
+ 'initialize-missing-server-info',
34
82
  'initialize-timeout',
35
83
  'server-exited',
36
84
  'spawn-failed'
@@ -45,6 +93,36 @@ const PROCESS_ISSUE_CODES = new Set([
45
93
  'server-exited',
46
94
  'spawn-failed'
47
95
  ]);
96
+ const ISSUE_CLASS_BY_CODE = new Map([
97
+ ['initialize-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
98
+ ['operation-missing-response', ISSUE_CLASSES.INSTALL_RUNTIME],
99
+ ['operation-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
100
+ ['python-buffered-stdio', ISSUE_CLASSES.INSTALL_RUNTIME],
101
+ ['server-crashed', ISSUE_CLASSES.INSTALL_RUNTIME],
102
+ ['server-exited', ISSUE_CLASSES.INSTALL_RUNTIME],
103
+ ['spawn-failed', ISSUE_CLASSES.INSTALL_RUNTIME],
104
+
105
+ ['static-stdout-write', ISSUE_CLASSES.STDIO_TRANSPORT],
106
+ ['stdout-content-length-framing', ISSUE_CLASSES.STDIO_TRANSPORT],
107
+ ['stdout-empty-line', ISSUE_CLASSES.STDIO_TRANSPORT],
108
+ ['stdout-non-json', ISSUE_CLASSES.STDIO_TRANSPORT],
109
+ ['stdout-without-newline', ISSUE_CLASSES.STDIO_TRANSPORT],
110
+
111
+ ['initialize-error', ISSUE_CLASSES.MCP_PROTOCOL],
112
+ ['initialize-invalid-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
113
+ ['initialize-invalid-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
114
+ ['initialize-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
115
+ ['initialize-invalid-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
116
+ ['initialize-missing-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
117
+ ['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
118
+ ['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
119
+ ['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
120
+ ['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
121
+ ['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
122
+ ['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
123
+ ['stdout-invalid-json-rpc', ISSUE_CLASSES.MCP_PROTOCOL],
124
+ ['stdout-unexpected-request-id', ISSUE_CLASSES.MCP_PROTOCOL]
125
+ ]);
48
126
 
49
127
  export async function runCli(argv) {
50
128
  const options = parseArgs(argv);
@@ -85,6 +163,9 @@ export async function runCli(argv) {
85
163
  path: options.scanPath,
86
164
  failOnFindings: options.failOnStatic
87
165
  };
166
+ if (result.fingerprint) {
167
+ result.fingerprint.staticScan = result.staticScan;
168
+ }
88
169
  result.staticFindings = scanSource(options.scanPath);
89
170
  if (options.failOnStatic) {
90
171
  for (const finding of result.staticFindings) {
@@ -193,8 +274,10 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
193
274
  throw new Error('repeat must be an integer >= 1');
194
275
  }
195
276
 
277
+ const singleRunOptions = { ...options, repeat: 1 };
278
+
196
279
  for (let index = 1; index <= repeat; index += 1) {
197
- const run = await guardStdioServer(commandWithArgs, options);
280
+ const run = await guardStdioServer(commandWithArgs, singleRunOptions);
198
281
  run.run = index;
199
282
  runs.push(run);
200
283
  for (const issue of run.issues) {
@@ -202,7 +285,8 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
202
285
  }
203
286
  }
204
287
 
205
- return finalizeResult({
288
+ const durationMs = Date.now() - startedAt;
289
+ const result = {
206
290
  schemaVersion: JSON_SCHEMA_VERSION,
207
291
  ok: !issues.some((issue) => issue.severity === 'error'),
208
292
  command: commandWithArgs,
@@ -213,8 +297,11 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
213
297
  checks: {},
214
298
  staticScan: defaultStaticScan(),
215
299
  staticFindings: [],
216
- durationMs: Date.now() - startedAt
217
- });
300
+ durationMs,
301
+ fingerprint: createFingerprint(commandWithArgs, options)
302
+ };
303
+ result.fingerprint.timings.totalMs = durationMs;
304
+ return finalizeResult(result);
218
305
  }
219
306
 
220
307
  export async function guardStdioServer(commandWithArgs, options = {}) {
@@ -231,6 +318,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
231
318
  let stdoutBuffer = '';
232
319
  let initialized = false;
233
320
  let endedByGuard = false;
321
+ let initializeResponseAt = 0;
234
322
  let timer;
235
323
  let child;
236
324
 
@@ -252,20 +340,33 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
252
340
  issues,
253
341
  checks: {},
254
342
  stderr: '',
343
+ process: defaultProcessInfo(timeoutMs),
255
344
  staticScan: defaultStaticScan(),
256
345
  staticFindings: [],
257
- durationMs: 0
346
+ durationMs: 0,
347
+ fingerprint: createFingerprint(commandWithArgs, {
348
+ protocol,
349
+ timeoutMs,
350
+ cwd: options.cwd,
351
+ operation,
352
+ env: options.env
353
+ })
258
354
  };
259
355
 
260
356
  return new Promise((resolve) => {
261
- function addIssue(severity, code, message) {
262
- issues.push({ severity, code, message });
357
+ function addIssue(severity, code, message, details = {}) {
358
+ issues.push({ ...details, severity, code, message });
263
359
  }
264
360
 
265
- function armTimeout(code, message) {
361
+ function armTimeout(code, message, timeoutPhase) {
266
362
  clearTimeout(timer);
363
+ result.process.phase = timeoutPhase;
267
364
  timer = setTimeout(() => {
268
- addIssue('error', code, message);
365
+ result.process.timedOut = true;
366
+ result.process.timeoutCode = code;
367
+ result.process.timeoutMs = timeoutMs;
368
+ result.process.outcome = 'timeout';
369
+ addIssue('error', code, message, timeoutIssueDetails(code, timeoutMs, timeoutPhase));
269
370
  finish();
270
371
  }, timeoutMs);
271
372
  }
@@ -281,8 +382,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
281
382
  result.durationMs = Date.now() - startedAt;
282
383
  result.stderr = Buffer.concat(stderrChunks).toString('utf8');
283
384
  result.initialized = initialized;
385
+ result.fingerprint.timings.startupMs = initializeResponseAt ? initializeResponseAt - startedAt : null;
386
+ result.fingerprint.timings.totalMs = result.durationMs;
387
+ const willTerminate = child && result.process.started && !child.killed && child.exitCode === null;
388
+ if (willTerminate) {
389
+ result.process.killedByGuard = true;
390
+ result.process.killSignal = 'SIGTERM';
391
+ result.process.killReason = result.process.timedOut ? 'timeout' : 'guard-finished';
392
+ if (!result.process.outcome || result.process.outcome === 'running') {
393
+ result.process.outcome = 'guard-terminated';
394
+ }
395
+ }
284
396
  finalizeResult(result);
285
- if (child && !child.killed && child.exitCode === null) {
397
+ if (willTerminate) {
286
398
  endedByGuard = true;
287
399
  child.kill('SIGTERM');
288
400
  }
@@ -290,6 +402,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
290
402
  }
291
403
 
292
404
  function send(message) {
405
+ if (!child?.stdin?.writable) return;
293
406
  child.stdin.write(`${JSON.stringify(message)}\n`);
294
407
  }
295
408
 
@@ -303,15 +416,34 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
303
416
  env,
304
417
  stdio: ['pipe', 'pipe', 'pipe']
305
418
  });
419
+ result.process.pid = child.pid ?? null;
306
420
 
307
- armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`);
421
+ armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`, 'initialize');
422
+
423
+ child.on('spawn', () => {
424
+ result.process.started = true;
425
+ result.process.pid = child.pid ?? null;
426
+ result.process.outcome = 'running';
427
+ });
308
428
 
309
429
  child.on('error', (error) => {
310
430
  clearTimeout(timer);
311
- addIssue('error', 'spawn-failed', error.message);
431
+ result.process.phase = 'startup';
432
+ result.process.outcome = 'spawn-failed';
433
+ result.process.spawnError = {
434
+ code: error.code || '',
435
+ message: error.message
436
+ };
437
+ addIssue('error', 'spawn-failed', error.message, {
438
+ detailCode: 'spawn-failed-before-startup',
439
+ phase: 'startup',
440
+ spawnErrorCode: error.code || ''
441
+ });
312
442
  finish();
313
443
  });
314
444
 
445
+ child.stdin.on('error', () => {});
446
+
315
447
  child.stdout.on('data', (chunk) => {
316
448
  stdoutBuffer += chunk.toString('utf8');
317
449
  const lines = stdoutBuffer.split(/\r?\n/);
@@ -329,17 +461,26 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
329
461
  child.on('exit', (code, signal) => {
330
462
  if (result.durationMs) return;
331
463
  clearTimeout(timer);
464
+ const exitPhase = initialized
465
+ ? result.operation && !result.operation.responded
466
+ ? 'operation'
467
+ : 'post-initialize'
468
+ : 'initialize';
469
+ result.process.phase = exitPhase;
470
+ result.process.outcome = 'exited';
471
+ result.process.exitCode = code;
472
+ result.process.signal = signal;
332
473
  if (stdoutBuffer.trim()) {
333
474
  addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
334
475
  }
335
476
  if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
336
- addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`);
477
+ addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`, exitIssueDetails('during-operation', code, signal));
337
478
  }
338
- if (!endedByGuard && initialized && code && code !== 0) {
339
- addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})`);
479
+ if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
480
+ addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
340
481
  }
341
482
  if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
342
- addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`);
483
+ addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('before-initialize', code, signal));
343
484
  }
344
485
  finish();
345
486
  });
@@ -386,15 +527,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
386
527
 
387
528
  frames.push(message);
388
529
 
389
- if (isResponseIdTypeMismatch(message, 1)) {
530
+ if (!initialized && isResponseIdTypeMismatch(message, 1)) {
390
531
  clearTimeout(timer);
391
532
  addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
392
533
  finish();
393
534
  return;
394
535
  }
395
536
 
396
- if (message.id === 1) {
537
+ if (!initialized && isResponseIdMismatch(message, 1)) {
397
538
  clearTimeout(timer);
539
+ addIssue('error', 'response-id-mismatch', `initialize response id ${JSON.stringify(message.id)} does not match request id 1`);
540
+ finish();
541
+ return;
542
+ }
543
+
544
+ if (!initialized && message.id === 1) {
545
+ clearTimeout(timer);
546
+ initializeResponseAt = Date.now();
398
547
  if (!isJsonRpcResponse(message)) {
399
548
  addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
400
549
  finish();
@@ -407,7 +556,17 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
407
556
  return;
408
557
  }
409
558
 
559
+ const initializeIssues = validateInitializeResult(message.result);
560
+ for (const issue of initializeIssues) {
561
+ addIssue(issue.severity, issue.code, issue.message);
562
+ }
563
+ if (initializeIssues.some((issue) => issue.severity === 'error')) {
564
+ finish();
565
+ return;
566
+ }
567
+
410
568
  initialized = true;
569
+ result.process.phase = operation ? 'operation' : 'post-initialize';
411
570
  result.negotiatedProtocol = message.result?.protocolVersion || '';
412
571
  send({ jsonrpc: '2.0', method: 'notifications/initialized' });
413
572
  if (operation) {
@@ -420,15 +579,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
420
579
  request.params = operation.params;
421
580
  }
422
581
  send(request);
423
- armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`);
582
+ armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`, 'operation');
424
583
  } else {
425
584
  finishSoon();
426
585
  }
427
- } else if (operation && isResponseIdTypeMismatch(message, 2)) {
586
+ } else if (initialized && !operation && isJsonRpcResponse(message)) {
587
+ clearTimeout(timer);
588
+ addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
589
+ finish();
590
+ } else if (initialized && operation && isResponseIdTypeMismatch(message, 2)) {
428
591
  clearTimeout(timer);
429
592
  addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
430
593
  finish();
431
- } else if (operation && message.id === 2) {
594
+ } else if (initialized && operation && isResponseIdMismatch(message, 2)) {
595
+ clearTimeout(timer);
596
+ addIssue('error', 'response-id-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not match request id 2`);
597
+ finish();
598
+ } else if (initialized && operation && message.id === 2) {
432
599
  clearTimeout(timer);
433
600
  if (!isJsonRpcResponse(message)) {
434
601
  addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
@@ -437,6 +604,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
437
604
  }
438
605
 
439
606
  result.operation.responded = true;
607
+ result.process.phase = 'post-initialize';
440
608
  if (message.error) {
441
609
  result.operation.error = message.error;
442
610
  addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
@@ -467,10 +635,63 @@ export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
467
635
  return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
468
636
  }
469
637
 
638
+ function defaultProcessInfo(timeoutMs) {
639
+ return {
640
+ started: false,
641
+ pid: null,
642
+ outcome: 'starting',
643
+ phase: 'initialize',
644
+ exitCode: null,
645
+ signal: null,
646
+ timedOut: false,
647
+ timeoutCode: '',
648
+ timeoutMs,
649
+ killedByGuard: false,
650
+ killSignal: '',
651
+ killReason: '',
652
+ spawnError: null
653
+ };
654
+ }
655
+
656
+ function timeoutIssueDetails(code, timeoutMs, phase) {
657
+ return {
658
+ detailCode: code === 'operation-timeout' ? 'request-timeout' : 'startup-timeout',
659
+ phase,
660
+ timeoutMs
661
+ };
662
+ }
663
+
664
+ function exitIssueDetails(position, code, signal) {
665
+ return {
666
+ detailCode: exitDetailCode(position, code, signal),
667
+ phase: position === 'during-operation'
668
+ ? 'operation'
669
+ : position === 'before-initialize'
670
+ ? 'initialize'
671
+ : 'post-initialize',
672
+ exitCode: code,
673
+ signal
674
+ };
675
+ }
676
+
677
+ function exitDetailCode(position, code, signal) {
678
+ if (signal) return `signal-exit-${position}`;
679
+ if (code === 0) return `clean-exit-${position}`;
680
+ return `nonzero-exit-${position}`;
681
+ }
682
+
683
+ function isAbnormalExit(code, signal) {
684
+ return signal !== null || (code !== null && code !== 0);
685
+ }
686
+
470
687
  function isResponseIdTypeMismatch(message, expectedId) {
471
688
  return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
472
689
  }
473
690
 
691
+ function isResponseIdMismatch(message, expectedId) {
692
+ return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) !== String(expectedId);
693
+ }
694
+
474
695
  function isJsonRpcResponse(message) {
475
696
  return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
476
697
  }
@@ -493,10 +714,38 @@ export function validateJsonRpc(message) {
493
714
  const hasResult = Object.hasOwn(message, 'result');
494
715
  const hasError = Object.hasOwn(message, 'error');
495
716
 
717
+ if (hasId && !isJsonRpcId(message.id)) {
718
+ return 'JSON-RPC id must be a string, integer number, or null';
719
+ }
720
+
721
+ if (hasMethod && hasId && message.id === null) {
722
+ return 'JSON-RPC request id must not be null';
723
+ }
724
+
496
725
  if (hasMethod && (hasResult || hasError)) {
497
726
  return 'request/notification frame must not include result or error';
498
727
  }
499
728
 
729
+ if (!hasMethod && hasResult && hasError) {
730
+ return 'response frame must not include both result and error';
731
+ }
732
+
733
+ if (!hasMethod && !hasId && (hasResult || hasError)) {
734
+ return 'response frame must include id';
735
+ }
736
+
737
+ if (!hasMethod && hasError) {
738
+ if (!message.error || typeof message.error !== 'object' || Array.isArray(message.error)) {
739
+ return 'JSON-RPC error must be an object';
740
+ }
741
+ if (!Number.isInteger(message.error.code)) {
742
+ return 'JSON-RPC error code must be an integer';
743
+ }
744
+ if (typeof message.error.message !== 'string') {
745
+ return 'JSON-RPC error message must be a string';
746
+ }
747
+ }
748
+
500
749
  if (hasId && !hasMethod && !hasResult && !hasError) {
501
750
  return 'response frame must include result or error';
502
751
  }
@@ -508,15 +757,452 @@ export function validateJsonRpc(message) {
508
757
  return '';
509
758
  }
510
759
 
760
+ function isJsonRpcId(id) {
761
+ return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isInteger(id));
762
+ }
763
+
764
+ function validateInitializeResult(result) {
765
+ const issues = [];
766
+
767
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
768
+ return [{
769
+ severity: 'error',
770
+ code: 'initialize-invalid-result',
771
+ message: 'initialize result must be an object with protocolVersion and capabilities'
772
+ }];
773
+ }
774
+
775
+ if (!Object.hasOwn(result, 'protocolVersion')) {
776
+ issues.push({
777
+ severity: 'error',
778
+ code: 'initialize-missing-protocol-version',
779
+ message: 'initialize result is missing protocolVersion'
780
+ });
781
+ } else if (typeof result.protocolVersion !== 'string' || !result.protocolVersion) {
782
+ issues.push({
783
+ severity: 'error',
784
+ code: 'initialize-invalid-protocol-version',
785
+ message: 'initialize result protocolVersion must be a non-empty string'
786
+ });
787
+ }
788
+
789
+ if (!Object.hasOwn(result, 'capabilities')) {
790
+ issues.push({
791
+ severity: 'error',
792
+ code: 'initialize-missing-capabilities',
793
+ message: 'initialize result is missing capabilities'
794
+ });
795
+ } else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
796
+ issues.push({
797
+ severity: 'error',
798
+ code: 'initialize-invalid-capabilities',
799
+ message: 'initialize result capabilities must be an object'
800
+ });
801
+ }
802
+
803
+ if (!Object.hasOwn(result, 'serverInfo')) {
804
+ issues.push({
805
+ severity: 'warning',
806
+ code: 'initialize-missing-server-info',
807
+ message: 'initialize result is missing serverInfo'
808
+ });
809
+ } else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
810
+ issues.push({
811
+ severity: 'warning',
812
+ code: 'initialize-invalid-server-info',
813
+ message: 'initialize result serverInfo should be an object'
814
+ });
815
+ }
816
+
817
+ return issues;
818
+ }
819
+
820
+ export function classifyIssueCode(code) {
821
+ return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
822
+ }
823
+
824
+ export function createFingerprint(commandWithArgs, options = {}) {
825
+ const cwd = path.resolve(options.cwd ?? process.cwd());
826
+ const operation = options.operation || null;
827
+
828
+ return {
829
+ guard: {
830
+ name: 'mcp-stdio-guard',
831
+ version: VERSION
832
+ },
833
+ command: {
834
+ executable: commandWithArgs[0] || '',
835
+ args: redactArgv(commandWithArgs.slice(1)),
836
+ argv: redactArgv(commandWithArgs)
837
+ },
838
+ cwd: {
839
+ requested: String(options.cwd ?? process.cwd()),
840
+ resolved: cwd,
841
+ exists: fs.existsSync(cwd)
842
+ },
843
+ protocol: options.protocol ?? DEFAULT_PROTOCOL,
844
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
845
+ repeat: options.repeat ?? 1,
846
+ operation: operation
847
+ ? {
848
+ method: operation.method,
849
+ hasParams: operation.params !== undefined
850
+ }
851
+ : null,
852
+ system: {
853
+ platform: process.platform,
854
+ arch: process.arch,
855
+ osRelease: os.release()
856
+ },
857
+ runtimes: detectRuntimeVersions(commandWithArgs),
858
+ package: detectPackageMetadata(commandWithArgs, cwd),
859
+ env: redactEnvMetadata(options.env ?? {}),
860
+ staticScan: defaultStaticScan(),
861
+ timings: {
862
+ startupMs: null,
863
+ totalMs: null
864
+ }
865
+ };
866
+ }
867
+
868
+ function redactEnvMetadata(env) {
869
+ const names = Object.keys(env).sort();
870
+ return {
871
+ inherited: true,
872
+ names,
873
+ values: Object.fromEntries(names.map((name) => [name, REDACTED]))
874
+ };
875
+ }
876
+
877
+ function redactArgv(argv) {
878
+ const redacted = [];
879
+ let redactNext = false;
880
+
881
+ for (const arg of argv) {
882
+ if (redactNext) {
883
+ redacted.push(REDACTED);
884
+ redactNext = false;
885
+ continue;
886
+ }
887
+
888
+ const secretAssignment = redactSecretAssignment(arg);
889
+ if (secretAssignment) {
890
+ redacted.push(secretAssignment);
891
+ continue;
892
+ }
893
+
894
+ redacted.push(arg);
895
+ if (isSecretFlag(arg)) {
896
+ redactNext = true;
897
+ }
898
+ }
899
+
900
+ return redacted;
901
+ }
902
+
903
+ function redactSecretAssignment(arg) {
904
+ const match = /^([^=\s]+)=(.*)$/.exec(arg);
905
+ if (!match) return '';
906
+
907
+ const [, name] = match;
908
+ return isSecretName(name) ? `${name}=${REDACTED}` : '';
909
+ }
910
+
911
+ function isSecretFlag(arg) {
912
+ if (!arg.startsWith('-')) return false;
913
+ const name = arg.replace(/^-+/, '').split(/[=\s]/)[0];
914
+ return isSecretName(name);
915
+ }
916
+
917
+ function isSecretName(name) {
918
+ return /(^|[-_])(api[-_]?key|auth|bearer|cookie|credential|password|passwd|private[-_]?key|pwd|secret|session|token)([-_]|$)/i.test(name);
919
+ }
920
+
921
+ function detectRuntimeVersions(commandWithArgs) {
922
+ const command = commandWithArgs[0] || '';
923
+ const base = path.basename(command).toLowerCase();
924
+ const runtimes = {
925
+ node: {
926
+ version: process.version,
927
+ role: isNodeCommand(command) ? 'guard-and-target' : 'guard'
928
+ }
929
+ };
930
+
931
+ if (isPythonCommand(command)) {
932
+ runtimes.python = executableVersion(command, ['--version']);
933
+ }
934
+
935
+ if (isNpmCommand(base) || isNpxCommand(base)) {
936
+ runtimes.npm = executableVersion('npm', ['--version']);
937
+ }
938
+
939
+ if (base === 'uv' || base === 'uvx') {
940
+ runtimes.uv = executableVersion(base === 'uvx' ? 'uv' : command, ['--version']);
941
+ }
942
+
943
+ if (base === 'docker') {
944
+ runtimes.docker = executableVersion(command, ['--version']);
945
+ }
946
+
947
+ return runtimes;
948
+ }
949
+
950
+ function executableVersion(command, args) {
951
+ const cacheKey = JSON.stringify([command, args]);
952
+ if (VERSION_PROBE_CACHE.has(cacheKey)) {
953
+ return { ...VERSION_PROBE_CACHE.get(cacheKey) };
954
+ }
955
+
956
+ const result = spawnSync(command, args, {
957
+ encoding: 'utf8',
958
+ timeout: 1000,
959
+ stdio: ['ignore', 'pipe', 'pipe']
960
+ });
961
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
962
+ const version = {
963
+ command,
964
+ version: output.split(/\r?\n/)[0] || '',
965
+ available: !result.error && result.status === 0 && result.signal === null,
966
+ status: result.status,
967
+ signal: result.signal
968
+ };
969
+ VERSION_PROBE_CACHE.set(cacheKey, version);
970
+ return { ...version };
971
+ }
972
+
973
+ function detectPackageMetadata(commandWithArgs, cwd) {
974
+ const command = commandWithArgs[0] || '';
975
+ const args = commandWithArgs.slice(1);
976
+ const base = path.basename(command).toLowerCase();
977
+
978
+ if (isNpxCommand(base)) {
979
+ return packageFromSpec('npm', firstPackageSpec(args));
980
+ }
981
+
982
+ if (isNpmCommand(base)) {
983
+ const subcommand = args[0] || '';
984
+ if (subcommand === 'exec' || subcommand === 'x') {
985
+ return packageFromSpec('npm', firstPackageSpec(args.slice(1)));
986
+ }
987
+ }
988
+
989
+ if (base === 'uvx') {
990
+ return packageFromSpec('uv', firstPackageSpec(args));
991
+ }
992
+
993
+ if (base === 'docker') {
994
+ const image = dockerImageSpec(args);
995
+ return image ? { manager: 'docker', name: image, versionSpec: '' } : null;
996
+ }
997
+
998
+ if (isNodeCommand(command)) {
999
+ const entrypoint = nodeEntrypointArg(args);
1000
+ return entrypoint ? localPackageMetadata(path.resolve(cwd, entrypoint)) : null;
1001
+ }
1002
+
1003
+ return null;
1004
+ }
1005
+
1006
+ function firstPackageSpec(args) {
1007
+ for (let index = 0; index < args.length; index += 1) {
1008
+ const arg = args[index];
1009
+ if (arg === '--') continue;
1010
+
1011
+ if (arg.startsWith('--package=')) {
1012
+ return arg.slice('--package='.length);
1013
+ }
1014
+
1015
+ if (arg === '--package' || arg === '-p') {
1016
+ return args[index + 1] || '';
1017
+ }
1018
+
1019
+ if (arg.startsWith('-')) {
1020
+ continue;
1021
+ }
1022
+
1023
+ return arg;
1024
+ }
1025
+
1026
+ return '';
1027
+ }
1028
+
1029
+ function nodeEntrypointArg(args) {
1030
+ let afterSeparator = false;
1031
+
1032
+ for (let index = 0; index < args.length; index += 1) {
1033
+ const arg = args[index];
1034
+
1035
+ if (!afterSeparator && arg === '--') {
1036
+ afterSeparator = true;
1037
+ continue;
1038
+ }
1039
+
1040
+ if (!afterSeparator && arg.startsWith('-')) {
1041
+ const optionName = arg.split('=')[0];
1042
+ if (NODE_EVAL_OPTIONS.has(optionName)) {
1043
+ return '';
1044
+ }
1045
+ if (NODE_OPTIONS_WITH_VALUES.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
1046
+ index += 1;
1047
+ }
1048
+ continue;
1049
+ }
1050
+
1051
+ return arg;
1052
+ }
1053
+
1054
+ return '';
1055
+ }
1056
+
1057
+ function packageFromSpec(manager, spec) {
1058
+ if (!spec) return null;
1059
+ const parsed = parsePackageSpec(spec);
1060
+ return {
1061
+ manager,
1062
+ name: parsed.name,
1063
+ versionSpec: parsed.versionSpec
1064
+ };
1065
+ }
1066
+
1067
+ function parsePackageSpec(spec) {
1068
+ if (spec.startsWith('@')) {
1069
+ const versionAt = spec.indexOf('@', 1);
1070
+ if (versionAt > -1) {
1071
+ return {
1072
+ name: spec.slice(0, versionAt),
1073
+ versionSpec: spec.slice(versionAt + 1)
1074
+ };
1075
+ }
1076
+ return { name: spec, versionSpec: '' };
1077
+ }
1078
+
1079
+ const versionAt = spec.lastIndexOf('@');
1080
+ if (versionAt > 0) {
1081
+ return {
1082
+ name: spec.slice(0, versionAt),
1083
+ versionSpec: spec.slice(versionAt + 1)
1084
+ };
1085
+ }
1086
+
1087
+ return { name: spec, versionSpec: '' };
1088
+ }
1089
+
1090
+ function dockerImageSpec(args) {
1091
+ const runIndex = args.indexOf('run');
1092
+ if (runIndex === -1) return '';
1093
+ const optionsWithValues = new Set([
1094
+ '-e',
1095
+ '--env',
1096
+ '-h',
1097
+ '--hostname',
1098
+ '-p',
1099
+ '--publish',
1100
+ '-u',
1101
+ '--user',
1102
+ '-v',
1103
+ '--volume',
1104
+ '-w',
1105
+ '--workdir',
1106
+ '--entrypoint',
1107
+ '--name',
1108
+ '--network',
1109
+ '--platform'
1110
+ ]);
1111
+
1112
+ for (let index = runIndex + 1; index < args.length; index += 1) {
1113
+ const arg = args[index];
1114
+ if (arg === '--') continue;
1115
+ if (arg.startsWith('-')) {
1116
+ const optionName = arg.split('=')[0];
1117
+ if (optionsWithValues.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
1118
+ index += 1;
1119
+ }
1120
+ continue;
1121
+ }
1122
+ return arg;
1123
+ }
1124
+
1125
+ return '';
1126
+ }
1127
+
1128
+ function localPackageMetadata(entry) {
1129
+ const packageJson = findNearestPackageJson(fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? entry : path.dirname(entry));
1130
+ if (!packageJson) return null;
1131
+
1132
+ try {
1133
+ const parsed = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
1134
+ return {
1135
+ manager: 'local',
1136
+ name: typeof parsed.name === 'string' ? parsed.name : '',
1137
+ versionSpec: typeof parsed.version === 'string' ? parsed.version : ''
1138
+ };
1139
+ } catch {
1140
+ return null;
1141
+ }
1142
+ }
1143
+
1144
+ function findNearestPackageJson(start) {
1145
+ let dir = path.resolve(start);
1146
+ while (dir !== path.dirname(dir)) {
1147
+ const candidate = path.join(dir, 'package.json');
1148
+ if (fs.existsSync(candidate)) return candidate;
1149
+ dir = path.dirname(dir);
1150
+ }
1151
+ return '';
1152
+ }
1153
+
1154
+ function isNodeCommand(command) {
1155
+ const base = path.basename(command).toLowerCase();
1156
+ return base === 'node' || base === 'node.exe' || command === process.execPath;
1157
+ }
1158
+
1159
+ function isPythonCommand(command) {
1160
+ const base = path.basename(command).toLowerCase();
1161
+ return /^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(base);
1162
+ }
1163
+
1164
+ function isNpmCommand(base) {
1165
+ return base === 'npm' || base === 'npm.cmd' || base === 'npm-cli.js';
1166
+ }
1167
+
1168
+ function isNpxCommand(base) {
1169
+ return base === 'npx' || base === 'npx.cmd';
1170
+ }
1171
+
511
1172
  function finalizeResult(result) {
512
1173
  result.schemaVersion = JSON_SCHEMA_VERSION;
513
1174
  result.staticScan ??= defaultStaticScan();
514
1175
  result.staticFindings ??= [];
1176
+ result.issues = normalizeIssues(result.issues ?? []);
515
1177
  result.ok = !result.issues.some((issue) => issue.severity === 'error');
516
1178
  result.checks = buildChecks(result);
1179
+ result.issueClasses = buildIssueClasses(result.issues);
1180
+ finalizeFingerprint(result);
517
1181
  return result;
518
1182
  }
519
1183
 
1184
+ function finalizeFingerprint(result) {
1185
+ if (!result.fingerprint) return;
1186
+ result.fingerprint.timings ??= {};
1187
+ result.fingerprint.timings.totalMs = result.durationMs ?? result.fingerprint.timings.totalMs ?? null;
1188
+
1189
+ if (Array.isArray(result.runs)) {
1190
+ result.fingerprint.runs = result.runs.map((run) => ({
1191
+ run: run.run,
1192
+ ok: run.ok,
1193
+ startupMs: run.fingerprint?.timings?.startupMs ?? null,
1194
+ totalMs: run.durationMs ?? run.fingerprint?.timings?.totalMs ?? null
1195
+ }));
1196
+ }
1197
+ }
1198
+
1199
+ function normalizeIssues(issues) {
1200
+ return issues.map((issue) => ({
1201
+ ...issue,
1202
+ class: classifyIssueCode(issue.code)
1203
+ }));
1204
+ }
1205
+
520
1206
  function buildChecks(result) {
521
1207
  const issues = result.issues ?? [];
522
1208
  const repeated = Array.isArray(result.runs);
@@ -537,6 +1223,13 @@ function buildChecks(result) {
537
1223
  };
538
1224
  }
539
1225
 
1226
+ function buildIssueClasses(issues) {
1227
+ return Object.fromEntries(ISSUE_CLASS_NAMES.map((className) => [
1228
+ className,
1229
+ buildIssueCheck(issues, (issue) => issue.class === className)
1230
+ ]));
1231
+ }
1232
+
540
1233
  function buildInitializeCheck(result, issues) {
541
1234
  const matched = issues.filter((issue) => (
542
1235
  INITIALIZE_ISSUE_CODES.has(issue.code)