@yawlabs/mcp-compliance 0.9.2 → 0.11.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
4
+ import { watch as fsWatch, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
5
5
  import { createRequire as createRequire2 } from "module";
6
6
  import { createInterface } from "readline/promises";
7
7
  import chalk2 from "chalk";
@@ -56,147 +56,8 @@ function renderBadgeSvg(input) {
56
56
  </svg>`;
57
57
  }
58
58
 
59
- // src/config.ts
60
- import { existsSync, readFileSync } from "fs";
61
- import { join, resolve } from "path";
62
- var SEARCH_NAMES = ["mcp-compliance.config.json", ".mcp-compliancerc.json", ".mcp-compliancerc"];
63
- function loadConfig(explicitPath, cwd = process.cwd()) {
64
- if (explicitPath) {
65
- const abs = resolve(cwd, explicitPath);
66
- if (!existsSync(abs)) throw new Error(`Config file not found: ${explicitPath}`);
67
- return parseConfig(readFileSync(abs, "utf8"), abs);
68
- }
69
- for (const name of SEARCH_NAMES) {
70
- const abs = join(cwd, name);
71
- if (existsSync(abs)) return parseConfig(readFileSync(abs, "utf8"), abs);
72
- }
73
- const pkgPath = join(cwd, "package.json");
74
- if (existsSync(pkgPath)) {
75
- try {
76
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
77
- if (pkg["mcp-compliance"]) return validate(pkg["mcp-compliance"], pkgPath);
78
- } catch {
79
- }
80
- }
81
- return null;
82
- }
83
- function parseConfig(contents, source) {
84
- let raw;
85
- try {
86
- raw = JSON.parse(contents);
87
- } catch (err) {
88
- throw new Error(`Failed to parse config at ${source}: ${err instanceof Error ? err.message : String(err)}`);
89
- }
90
- return validate(raw, source);
91
- }
92
- function validate(raw, source) {
93
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
94
- throw new Error(`Config at ${source} must be a JSON object`);
95
- }
96
- const obj = raw;
97
- const allowed = /* @__PURE__ */ new Set([
98
- "target",
99
- "timeout",
100
- "preflightTimeout",
101
- "retries",
102
- "only",
103
- "skip",
104
- "strict",
105
- "format",
106
- "verbose"
107
- ]);
108
- for (const k of Object.keys(obj)) {
109
- if (!allowed.has(k)) {
110
- throw new Error(`Config at ${source}: unknown key "${k}"`);
111
- }
112
- }
113
- if (obj.target !== void 0) validateTarget(obj.target, source);
114
- if (obj.format !== void 0 && !["terminal", "json", "sarif"].includes(obj.format)) {
115
- throw new Error(`Config at ${source}: format must be one of terminal, json, sarif`);
116
- }
117
- return obj;
118
- }
119
- function validateTarget(t, source) {
120
- if (!t || typeof t !== "object" || Array.isArray(t)) {
121
- throw new Error(`Config at ${source}: target must be an object`);
122
- }
123
- const obj = t;
124
- if (obj.type !== "http" && obj.type !== "stdio") {
125
- throw new Error(`Config at ${source}: target.type must be "http" or "stdio"`);
126
- }
127
- if (obj.type === "http" && typeof obj.url !== "string") {
128
- throw new Error(`Config at ${source}: http target requires string "url"`);
129
- }
130
- if (obj.type === "stdio" && typeof obj.command !== "string") {
131
- throw new Error(`Config at ${source}: stdio target requires string "command"`);
132
- }
133
- }
134
-
135
- // src/mcp/server.ts
136
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
137
- import { dirname, join as join2, resolve as resolve2 } from "path";
138
- import { fileURLToPath } from "url";
139
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
140
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
141
-
142
- // src/mcp/tools.ts
143
- import { z } from "zod";
144
-
145
- // src/runner.ts
146
- import { createRequire } from "module";
147
- import { request as request2 } from "undici";
148
-
149
- // src/badge.ts
150
- import { createHash } from "crypto";
151
- function urlHash(url) {
152
- return createHash("sha256").update(url).digest("hex").slice(0, 12);
153
- }
154
- function generateBadge(url) {
155
- const hash = urlHash(url);
156
- const imageUrl = `https://mcp.hosting/api/compliance/ext/${hash}/badge`;
157
- const reportUrl = `https://mcp.hosting/compliance/ext/${hash}`;
158
- return {
159
- imageUrl,
160
- reportUrl,
161
- markdown: `[![MCP Compliant](${imageUrl})](${reportUrl})`,
162
- html: `<a href="${reportUrl}"><img src="${imageUrl}" alt="MCP Compliant"></a>`
163
- };
164
- }
165
-
166
- // src/grader.ts
167
- function computeGrade(score) {
168
- if (score >= 90) return "A";
169
- if (score >= 75) return "B";
170
- if (score >= 60) return "C";
171
- if (score >= 40) return "D";
172
- return "F";
173
- }
174
- function computeScore(tests) {
175
- const total = tests.length;
176
- const passed = tests.filter((t) => t.passed).length;
177
- const failed = total - passed;
178
- const requiredTests = tests.filter((t) => t.required);
179
- const requiredPassed = requiredTests.filter((t) => t.passed).length;
180
- const requiredScore = requiredTests.length > 0 ? requiredPassed / requiredTests.length * 70 : 70;
181
- const optionalTests = tests.filter((t) => !t.required);
182
- const optionalPassed = optionalTests.filter((t) => t.passed).length;
183
- const optionalScore = optionalTests.length > 0 ? optionalPassed / optionalTests.length * 30 : 30;
184
- const score = Math.round(requiredScore + optionalScore);
185
- const overall = requiredPassed === requiredTests.length ? passed === total ? "pass" : "partial" : "fail";
186
- const categories = {};
187
- for (const t of tests) {
188
- if (!categories[t.category]) categories[t.category] = { passed: 0, total: 0 };
189
- categories[t.category].total++;
190
- if (t.passed) categories[t.category].passed++;
191
- }
192
- return {
193
- score,
194
- grade: computeGrade(score),
195
- overall,
196
- summary: { total, passed, failed, required: requiredTests.length, requiredPassed },
197
- categories
198
- };
199
- }
59
+ // src/benchmark.ts
60
+ import { performance } from "perf_hooks";
200
61
 
201
62
  // src/transport/http.ts
202
63
  import { request } from "undici";
@@ -353,9 +214,21 @@ function createStdioTransport(opts) {
353
214
  let exited = false;
354
215
  let exitCode = null;
355
216
  let spawnError = null;
217
+ let spawned = false;
356
218
  const pending = /* @__PURE__ */ new Map();
357
219
  let stdoutBuffer = "";
358
220
  let stderrBuffer = "";
221
+ const spawnReady = new Promise((resolve3, reject) => {
222
+ child.once("spawn", () => {
223
+ spawned = true;
224
+ resolve3();
225
+ });
226
+ child.once("error", (err) => {
227
+ if (!spawned) reject(err);
228
+ });
229
+ });
230
+ spawnReady.catch(() => {
231
+ });
359
232
  child.on("error", (err) => {
360
233
  spawnError = err;
361
234
  rejectAllPending(err);
@@ -425,6 +298,15 @@ function createStdioTransport(opts) {
425
298
  ${snippet.replace(/\n/g, "\n ")}`;
426
299
  }
427
300
  async function writeLine(line) {
301
+ if (!spawned && !spawnError) {
302
+ try {
303
+ await spawnReady;
304
+ } catch (err) {
305
+ throw new Error(
306
+ annotateWithStderr(`stdio transport: spawn failed \u2014 ${err instanceof Error ? err.message : String(err)}`)
307
+ );
308
+ }
309
+ }
428
310
  if (exited) {
429
311
  throw new Error(annotateWithStderr(`stdio transport: child has exited (code ${exitCode})`));
430
312
  }
@@ -518,7 +400,349 @@ function createStdioTransport(opts) {
518
400
  return transport;
519
401
  }
520
402
 
403
+ // src/benchmark.ts
404
+ function pct(sorted, p) {
405
+ if (sorted.length === 0) return 0;
406
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
407
+ return sorted[idx];
408
+ }
409
+ async function runBenchmark(target, opts = {}) {
410
+ const requests = opts.requests ?? 100;
411
+ const concurrency = Math.max(1, opts.concurrency ?? 1);
412
+ const timeout = opts.timeout ?? 15e3;
413
+ const transport = target.type === "http" ? createHttpTransport({ url: target.url, headers: target.headers }) : createStdioTransport({
414
+ command: target.command,
415
+ args: target.args,
416
+ env: target.env,
417
+ cwd: target.cwd
418
+ });
419
+ let nextId = 1;
420
+ try {
421
+ await transport.request(
422
+ "initialize",
423
+ { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "mcp-compliance-bench", version: "1" } },
424
+ () => nextId++,
425
+ { timeout }
426
+ );
427
+ await transport.notify("notifications/initialized", void 0, { timeout });
428
+ } catch {
429
+ }
430
+ const latencies = [];
431
+ let succeeded = 0;
432
+ let failed = 0;
433
+ const overallStart = performance.now();
434
+ let inFlight = 0;
435
+ let issued = 0;
436
+ let resolveAll;
437
+ const allDone = new Promise((r) => {
438
+ resolveAll = r;
439
+ });
440
+ function tick() {
441
+ while (inFlight < concurrency && issued < requests) {
442
+ issued++;
443
+ inFlight++;
444
+ const t0 = performance.now();
445
+ transport.request("ping", void 0, () => nextId++, { timeout }).then(() => {
446
+ latencies.push(performance.now() - t0);
447
+ succeeded++;
448
+ }).catch(() => {
449
+ latencies.push(performance.now() - t0);
450
+ failed++;
451
+ }).finally(() => {
452
+ inFlight--;
453
+ opts.onProgress?.(succeeded + failed, requests);
454
+ if (succeeded + failed >= requests) resolveAll();
455
+ else tick();
456
+ });
457
+ }
458
+ }
459
+ tick();
460
+ await allDone;
461
+ const durationMs = performance.now() - overallStart;
462
+ await transport.close().catch(() => {
463
+ });
464
+ const sorted = [...latencies].sort((a, b) => a - b);
465
+ const mean = sorted.reduce((s, v) => s + v, 0) / Math.max(1, sorted.length);
466
+ const targetDescription = target.type === "http" ? target.url : `stdio:${target.command} ${target.args?.join(" ") ?? ""}`;
467
+ return {
468
+ target: targetDescription,
469
+ requests,
470
+ succeeded,
471
+ failed,
472
+ durationMs,
473
+ throughputPerSec: durationMs > 0 ? requests / durationMs * 1e3 : 0,
474
+ latencyMs: {
475
+ min: sorted[0] ?? 0,
476
+ p50: pct(sorted, 50),
477
+ p90: pct(sorted, 90),
478
+ p95: pct(sorted, 95),
479
+ p99: pct(sorted, 99),
480
+ max: sorted[sorted.length - 1] ?? 0,
481
+ mean
482
+ }
483
+ };
484
+ }
485
+ function formatBenchmark(result) {
486
+ const lines = [];
487
+ lines.push(`Benchmark: ${result.target}`);
488
+ lines.push(
489
+ ` ${result.requests} requests in ${result.durationMs.toFixed(0)}ms (${result.throughputPerSec.toFixed(1)} req/s)`
490
+ );
491
+ lines.push(` ${result.succeeded} succeeded \xB7 ${result.failed} failed`);
492
+ lines.push("");
493
+ lines.push("Latency (ms):");
494
+ lines.push(` min ${result.latencyMs.min.toFixed(2)}`);
495
+ lines.push(` mean ${result.latencyMs.mean.toFixed(2)}`);
496
+ lines.push(` p50 ${result.latencyMs.p50.toFixed(2)}`);
497
+ lines.push(` p90 ${result.latencyMs.p90.toFixed(2)}`);
498
+ lines.push(` p95 ${result.latencyMs.p95.toFixed(2)}`);
499
+ lines.push(` p99 ${result.latencyMs.p99.toFixed(2)}`);
500
+ lines.push(` max ${result.latencyMs.max.toFixed(2)}`);
501
+ return lines.join("\n");
502
+ }
503
+
504
+ // src/config.ts
505
+ import { existsSync, readFileSync } from "fs";
506
+ import { join, resolve } from "path";
507
+ var SEARCH_NAMES = ["mcp-compliance.config.json", ".mcp-compliancerc.json", ".mcp-compliancerc"];
508
+ function loadConfig(explicitPath, cwd = process.cwd()) {
509
+ if (explicitPath) {
510
+ const abs = resolve(cwd, explicitPath);
511
+ if (!existsSync(abs)) throw new Error(`Config file not found: ${explicitPath}`);
512
+ return parseConfig(readFileSync(abs, "utf8"), abs);
513
+ }
514
+ for (const name of SEARCH_NAMES) {
515
+ const abs = join(cwd, name);
516
+ if (existsSync(abs)) return parseConfig(readFileSync(abs, "utf8"), abs);
517
+ }
518
+ const pkgPath = join(cwd, "package.json");
519
+ if (existsSync(pkgPath)) {
520
+ try {
521
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
522
+ if (pkg["mcp-compliance"]) return validate(pkg["mcp-compliance"], pkgPath);
523
+ } catch {
524
+ }
525
+ }
526
+ return null;
527
+ }
528
+ function parseConfig(contents, source) {
529
+ let raw;
530
+ try {
531
+ raw = JSON.parse(contents);
532
+ } catch (err) {
533
+ throw new Error(`Failed to parse config at ${source}: ${err instanceof Error ? err.message : String(err)}`);
534
+ }
535
+ return validate(raw, source);
536
+ }
537
+ function validate(raw, source) {
538
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
539
+ throw new Error(`Config at ${source} must be a JSON object`);
540
+ }
541
+ const obj = raw;
542
+ const allowed = /* @__PURE__ */ new Set([
543
+ "target",
544
+ "timeout",
545
+ "preflightTimeout",
546
+ "retries",
547
+ "only",
548
+ "skip",
549
+ "strict",
550
+ "format",
551
+ "verbose"
552
+ ]);
553
+ for (const k of Object.keys(obj)) {
554
+ if (!allowed.has(k)) {
555
+ throw new Error(`Config at ${source}: unknown key "${k}"`);
556
+ }
557
+ }
558
+ if (obj.target !== void 0) validateTarget(obj.target, source);
559
+ if (obj.format !== void 0 && !["terminal", "json", "sarif"].includes(obj.format)) {
560
+ throw new Error(`Config at ${source}: format must be one of terminal, json, sarif`);
561
+ }
562
+ return obj;
563
+ }
564
+ function validateTarget(t, source) {
565
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
566
+ throw new Error(`Config at ${source}: target must be an object`);
567
+ }
568
+ const obj = t;
569
+ if (obj.type !== "http" && obj.type !== "stdio") {
570
+ throw new Error(`Config at ${source}: target.type must be "http" or "stdio"`);
571
+ }
572
+ if (obj.type === "http" && typeof obj.url !== "string") {
573
+ throw new Error(`Config at ${source}: http target requires string "url"`);
574
+ }
575
+ if (obj.type === "stdio" && typeof obj.command !== "string") {
576
+ throw new Error(`Config at ${source}: stdio target requires string "command"`);
577
+ }
578
+ }
579
+
580
+ // src/diff.ts
581
+ function diffReports(baseline, current) {
582
+ const baseById = new Map(baseline.tests.map((t) => [t.id, t]));
583
+ const curById = new Map(current.tests.map((t) => [t.id, t]));
584
+ const regressions = [];
585
+ const fixes = [];
586
+ const newFailures = [];
587
+ const newPasses = [];
588
+ const removed = [];
589
+ for (const [id, cur] of curById) {
590
+ const base = baseById.get(id);
591
+ if (!base) {
592
+ const entry = {
593
+ id,
594
+ name: cur.name,
595
+ category: cur.category,
596
+ required: cur.required,
597
+ kind: cur.passed ? "newPass" : "newFail",
598
+ currentDetails: cur.details
599
+ };
600
+ (cur.passed ? newPasses : newFailures).push(entry);
601
+ continue;
602
+ }
603
+ if (base.passed !== cur.passed) {
604
+ const entry = {
605
+ id,
606
+ name: cur.name,
607
+ category: cur.category,
608
+ required: cur.required,
609
+ kind: cur.passed ? "fix" : "regression",
610
+ baselineDetails: base.details,
611
+ currentDetails: cur.details
612
+ };
613
+ (cur.passed ? fixes : regressions).push(entry);
614
+ }
615
+ }
616
+ for (const [id, base] of baseById) {
617
+ if (!curById.has(id)) {
618
+ removed.push({
619
+ id,
620
+ name: base.name,
621
+ category: base.category,
622
+ required: base.required,
623
+ kind: "removed",
624
+ baselineDetails: base.details
625
+ });
626
+ }
627
+ }
628
+ return {
629
+ baselineGrade: baseline.grade,
630
+ currentGrade: current.grade,
631
+ baselineScore: baseline.score,
632
+ currentScore: current.score,
633
+ regressions,
634
+ fixes,
635
+ newFailures,
636
+ newPasses,
637
+ removed
638
+ };
639
+ }
640
+ function formatDiff(summary) {
641
+ const lines = [];
642
+ const arrow = summary.baselineGrade === summary.currentGrade ? "\u2192" : "\u2192";
643
+ lines.push(
644
+ `Grade ${summary.baselineGrade} (${summary.baselineScore}%) ${arrow} ${summary.currentGrade} (${summary.currentScore}%)`
645
+ );
646
+ lines.push("");
647
+ function section(label, entries) {
648
+ if (!entries.length) return;
649
+ lines.push(`${label} (${entries.length}):`);
650
+ for (const e of entries) {
651
+ const req = e.required ? " [required]" : "";
652
+ lines.push(` - ${e.id}${req}: ${e.name}`);
653
+ if (e.baselineDetails && e.currentDetails && e.baselineDetails !== e.currentDetails) {
654
+ lines.push(` was: ${e.baselineDetails}`);
655
+ lines.push(` now: ${e.currentDetails}`);
656
+ } else if (e.currentDetails) {
657
+ lines.push(` ${e.currentDetails}`);
658
+ } else if (e.baselineDetails) {
659
+ lines.push(` ${e.baselineDetails}`);
660
+ }
661
+ }
662
+ lines.push("");
663
+ }
664
+ section("Regressions", summary.regressions);
665
+ section("Fixes", summary.fixes);
666
+ section("New failures", summary.newFailures);
667
+ section("New passes", summary.newPasses);
668
+ section("Removed tests", summary.removed);
669
+ if (summary.regressions.length + summary.fixes.length + summary.newFailures.length + summary.newPasses.length + summary.removed.length === 0) {
670
+ lines.push("No changes between baseline and current.");
671
+ }
672
+ return lines.join("\n");
673
+ }
674
+ function hasRegressions(summary) {
675
+ return summary.regressions.length > 0 || summary.newFailures.some((e) => e.required);
676
+ }
677
+
678
+ // src/mcp/server.ts
679
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
680
+ import { dirname, join as join2, resolve as resolve2 } from "path";
681
+ import { fileURLToPath } from "url";
682
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
683
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
684
+
685
+ // src/mcp/tools.ts
686
+ import { z } from "zod";
687
+
688
+ // src/runner.ts
689
+ import { createRequire } from "module";
690
+ import { request as request2 } from "undici";
691
+
692
+ // src/badge.ts
693
+ import { createHash } from "crypto";
694
+ function urlHash(url) {
695
+ return createHash("sha256").update(url).digest("hex").slice(0, 24);
696
+ }
697
+ function generateBadge(url) {
698
+ const hash = urlHash(url);
699
+ const imageUrl = `https://mcp.hosting/api/compliance/ext/${hash}/badge`;
700
+ const reportUrl = `https://mcp.hosting/compliance/ext/${hash}`;
701
+ return {
702
+ imageUrl,
703
+ reportUrl,
704
+ markdown: `[![MCP Compliant](${imageUrl})](${reportUrl})`,
705
+ html: `<a href="${reportUrl}"><img src="${imageUrl}" alt="MCP Compliant"></a>`
706
+ };
707
+ }
708
+
709
+ // src/grader.ts
710
+ function computeGrade(score) {
711
+ if (score >= 90) return "A";
712
+ if (score >= 75) return "B";
713
+ if (score >= 60) return "C";
714
+ if (score >= 40) return "D";
715
+ return "F";
716
+ }
717
+ function computeScore(tests) {
718
+ const total = tests.length;
719
+ const passed = tests.filter((t) => t.passed).length;
720
+ const failed = total - passed;
721
+ const requiredTests = tests.filter((t) => t.required);
722
+ const requiredPassed = requiredTests.filter((t) => t.passed).length;
723
+ const requiredScore = requiredTests.length > 0 ? requiredPassed / requiredTests.length * 70 : 70;
724
+ const optionalTests = tests.filter((t) => !t.required);
725
+ const optionalPassed = optionalTests.filter((t) => t.passed).length;
726
+ const optionalScore = optionalTests.length > 0 ? optionalPassed / optionalTests.length * 30 : 30;
727
+ const score = Math.round(requiredScore + optionalScore);
728
+ const overall = requiredPassed === requiredTests.length ? passed === total ? "pass" : "partial" : "fail";
729
+ const categories = {};
730
+ for (const t of tests) {
731
+ if (!categories[t.category]) categories[t.category] = { passed: 0, total: 0 };
732
+ categories[t.category].total++;
733
+ if (t.passed) categories[t.category].passed++;
734
+ }
735
+ return {
736
+ score,
737
+ grade: computeGrade(score),
738
+ overall,
739
+ summary: { total, passed, failed, required: requiredTests.length, requiredPassed },
740
+ categories
741
+ };
742
+ }
743
+
521
744
  // src/types.ts
745
+ var REPORT_SCHEMA_VERSION = "1";
522
746
  var TEST_DEFINITIONS = [
523
747
  // ── Transport (13 tests) ─────────────────────────────────────────
524
748
  {
@@ -823,6 +1047,42 @@ var TEST_DEFINITIONS = [
823
1047
  description: "Sends a tools/call request with _meta.progressToken and checks if the server sends progress notifications via SSE. Progress support is optional but recommended for long-running operations.",
824
1048
  recommendation: "When a request includes _meta.progressToken, send notifications/progress events via SSE to report progress. Include progressToken, progress (current), and optionally total fields."
825
1049
  },
1050
+ {
1051
+ id: "lifecycle-sampling-capability",
1052
+ name: "Sampling capability shape",
1053
+ category: "lifecycle",
1054
+ required: false,
1055
+ specRef: "client/sampling",
1056
+ description: "If the server's initialize response or serverInfo implies it uses client-side sampling (sampling/createMessage), verify the capability declaration shape. Currently this is an advisory shape check \u2014 actually exercising the server\u2192client flow requires a client-side sampling handler and is out of scope.",
1057
+ recommendation: "Sampling is a client capability (the client provides LLM access to the server). Servers don't declare sampling in their own capabilities; they just call sampling/createMessage against clients that advertise it. No server-side action required."
1058
+ },
1059
+ {
1060
+ id: "lifecycle-roots-capability",
1061
+ name: "Roots capability shape",
1062
+ category: "lifecycle",
1063
+ required: false,
1064
+ specRef: "client/roots",
1065
+ description: "Roots (filesystem root paths) is a client capability. This test verifies that if a server sends roots/list requests, it handles gracefully when the client doesn't declare the roots capability (i.e., doesn't crash).",
1066
+ recommendation: "Before calling roots/list, check if the initialized client capabilities include 'roots'. If not, skip the call \u2014 the client can't respond. Never assume roots is available; it's opt-in on the client side."
1067
+ },
1068
+ {
1069
+ id: "lifecycle-elicitation-capability",
1070
+ name: "Elicitation capability shape",
1071
+ category: "lifecycle",
1072
+ required: false,
1073
+ specRef: "client/elicitation",
1074
+ description: "Elicitation (asking the user for structured input mid-operation) is a client capability added in 2025-11-25. This test verifies servers that use elicitation/create handle the case where clients don't support it.",
1075
+ recommendation: "Before calling elicitation/create, check the initialized client capabilities. If elicitation is absent, fall back to a safer default (ask once up-front via tool parameters, or fail cleanly with a clear error)."
1076
+ },
1077
+ {
1078
+ id: "lifecycle-meta-tolerance",
1079
+ name: "Tolerates _meta field on requests",
1080
+ category: "lifecycle",
1081
+ required: false,
1082
+ specRef: "basic/utilities#_meta",
1083
+ description: "Sends a ping with params._meta = { extra: 'value' } and verifies the server doesn't error. The 2025-11-25 spec allows arbitrary _meta on any request; servers should ignore unknown _meta fields gracefully.",
1084
+ recommendation: "Treat the _meta field as opaque \u2014 pass it through your request validator, but do not reject requests for unknown _meta keys. The MCP spec reserves _meta for protocol/transport metadata and forward-compat extensibility."
1085
+ },
826
1086
  // ── Tools (4 tests) ──────────────────────────────────────────────
827
1087
  {
828
1088
  id: "tools-list",
@@ -1535,7 +1795,7 @@ async function runComplianceSuite(target, options = {}) {
1535
1795
  if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1536
1796
  }
1537
1797
  }
1538
- tests.push({
1798
+ const result = {
1539
1799
  id,
1540
1800
  name,
1541
1801
  category,
@@ -1544,8 +1804,10 @@ async function runComplianceSuite(target, options = {}) {
1544
1804
  details: lastResult.details,
1545
1805
  durationMs: Date.now() - start,
1546
1806
  specRef: `${SPEC_BASE}/${specRef}`
1547
- });
1807
+ };
1808
+ tests.push(result);
1548
1809
  options.onProgress?.(id, lastResult.passed, lastResult.details);
1810
+ options.onTestComplete?.(result);
1549
1811
  }
1550
1812
  await test(
1551
1813
  "transport-post",
@@ -1715,7 +1977,11 @@ async function runComplianceSuite(target, options = {}) {
1715
1977
  try {
1716
1978
  initRes = await rpc("initialize", {
1717
1979
  protocolVersion: SPEC_VERSION,
1718
- capabilities: {},
1980
+ capabilities: {
1981
+ sampling: {},
1982
+ roots: { listChanged: true },
1983
+ elicitation: {}
1984
+ },
1719
1985
  clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
1720
1986
  });
1721
1987
  const result = initRes?.body?.result;
@@ -2137,6 +2403,69 @@ async function runComplianceSuite(target, options = {}) {
2137
2403
  }
2138
2404
  }
2139
2405
  );
2406
+ await test(
2407
+ "lifecycle-sampling-capability",
2408
+ "Sampling capability shape",
2409
+ "lifecycle",
2410
+ false,
2411
+ "client/sampling",
2412
+ async () => {
2413
+ if (!initRes || initRes.body?.error) {
2414
+ return { passed: false, details: "Server rejected initialize" };
2415
+ }
2416
+ return {
2417
+ passed: true,
2418
+ details: "Server accepted initialize with client sampling capability. Full server\u2192client sampling flow not exercised."
2419
+ };
2420
+ }
2421
+ );
2422
+ await test("lifecycle-roots-capability", "Roots capability shape", "lifecycle", false, "client/roots", async () => {
2423
+ if (!initRes || initRes.body?.error) {
2424
+ return { passed: false, details: "Server rejected initialize" };
2425
+ }
2426
+ return {
2427
+ passed: true,
2428
+ details: "Server accepted initialize. Full server\u2192client roots/list flow not exercised (requires a roots-aware client)."
2429
+ };
2430
+ });
2431
+ await test(
2432
+ "lifecycle-elicitation-capability",
2433
+ "Elicitation capability shape",
2434
+ "lifecycle",
2435
+ false,
2436
+ "client/elicitation",
2437
+ async () => {
2438
+ if (!initRes || initRes.body?.error) {
2439
+ return { passed: false, details: "Server rejected initialize" };
2440
+ }
2441
+ return {
2442
+ passed: true,
2443
+ details: "Server accepted initialize. Full server\u2192client elicitation/create flow not exercised."
2444
+ };
2445
+ }
2446
+ );
2447
+ await test(
2448
+ "lifecycle-meta-tolerance",
2449
+ "Tolerates _meta field on requests",
2450
+ "lifecycle",
2451
+ false,
2452
+ "basic/utilities#_meta",
2453
+ async () => {
2454
+ try {
2455
+ const res = await rpc("ping", { _meta: { "mcp-compliance/probe": "1" } });
2456
+ const body = res.body;
2457
+ if (body.error) {
2458
+ return {
2459
+ passed: false,
2460
+ details: `Server rejected _meta on ping (code ${body.error.code}). _meta should be ignored, not error.`
2461
+ };
2462
+ }
2463
+ return { passed: true, details: "Server accepted ping with arbitrary _meta field" };
2464
+ } catch (err) {
2465
+ return { passed: false, details: `Error: ${err instanceof Error ? err.message : String(err)}` };
2466
+ }
2467
+ }
2468
+ );
2140
2469
  await test(
2141
2470
  "transport-content-type-init",
2142
2471
  "Initialize response has valid content type",
@@ -4019,6 +4348,7 @@ async function runComplianceSuite(target, options = {}) {
4019
4348
  const { score, grade, overall, summary, categories } = computeScore(tests);
4020
4349
  const badge = generateBadge(displayUrl);
4021
4350
  return {
4351
+ schemaVersion: REPORT_SCHEMA_VERSION,
4022
4352
  specVersion: SPEC_VERSION,
4023
4353
  toolVersion: TOOL_VERSION,
4024
4354
  url: displayUrl,
@@ -4610,6 +4940,134 @@ function formatMarkdown(report) {
4610
4940
  }
4611
4941
  return lines.join("\n");
4612
4942
  }
4943
+ function formatHtml(report) {
4944
+ const gradeColors = {
4945
+ A: "#10b981",
4946
+ B: "#84cc16",
4947
+ C: "#eab308",
4948
+ D: "#f97316",
4949
+ F: "#ef4444"
4950
+ };
4951
+ const gradeColor2 = gradeColors[report.grade] || "#6b7280";
4952
+ function esc(s) {
4953
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4954
+ }
4955
+ const failed = report.tests.filter((t) => !t.passed);
4956
+ const grouped = /* @__PURE__ */ new Map();
4957
+ for (const cat of CATEGORY_ORDER) grouped.set(cat, []);
4958
+ for (const t of report.tests) grouped.get(t.category)?.push(t);
4959
+ const isStdio = report.url.startsWith("stdio:");
4960
+ return `<!doctype html>
4961
+ <html lang="en">
4962
+ <head>
4963
+ <meta charset="utf-8">
4964
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4965
+ <title>MCP Compliance \u2014 ${esc(report.url)} \u2014 Grade ${report.grade}</title>
4966
+ <style>
4967
+ :root { color-scheme: light dark; }
4968
+ *, *::before, *::after { box-sizing: border-box; }
4969
+ body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: #0b0f17; color: #e5e7eb; }
4970
+ @media (prefers-color-scheme: light) { body { background: #f9fafb; color: #111827; } .card { background: #fff !important; border-color: #e5e7eb !important; } .muted { color: #6b7280 !important; } }
4971
+ .container { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
4972
+ header { text-align: center; margin-bottom: 32px; }
4973
+ h1 { font-size: 28px; margin: 0 0 4px; }
4974
+ .muted { color: #9ca3af; font-size: 13px; }
4975
+ .grade-card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 32px; margin: 24px 0; text-align: center; }
4976
+ .grade-letter { font-size: 96px; font-weight: 700; line-height: 1; color: ${gradeColor2}; margin: 0; }
4977
+ .grade-score { font-size: 24px; font-weight: 600; margin-top: 4px; }
4978
+ .grade-overall { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; text-transform: uppercase; margin-top: 12px; }
4979
+ .grade-overall.pass { background: #064e3b; color: #6ee7b7; }
4980
+ .grade-overall.partial { background: #78350f; color: #fcd34d; }
4981
+ .grade-overall.fail { background: #7f1d1d; color: #fca5a5; }
4982
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 24px 0; }
4983
+ .cat-card { background: #111827; border: 1px solid #1f2937; border-radius: 8px; padding: 16px; text-align: center; }
4984
+ .cat-stat { font-size: 24px; font-weight: 700; }
4985
+ .cat-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; }
4986
+ .cat-stat.full { color: #10b981; }
4987
+ .cat-stat.partial { color: #eab308; }
4988
+ .cat-stat.empty { color: #ef4444; }
4989
+ .card { background: #111827; border: 1px solid #1f2937; border-radius: 8px; padding: 20px; margin: 16px 0; }
4990
+ .card h2 { margin-top: 0; font-size: 16px; }
4991
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
4992
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #1f2937; vertical-align: top; }
4993
+ th { font-weight: 600; color: #9ca3af; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
4994
+ td.status { white-space: nowrap; font-weight: 600; }
4995
+ td.status.pass { color: #10b981; }
4996
+ td.status.fail { color: #ef4444; }
4997
+ td.id { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: #9ca3af; }
4998
+ .badge-tag { display: inline-block; background: #1f2937; color: #fcd34d; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
4999
+ .warn { background: #78350f; color: #fcd34d; padding: 12px 16px; border-radius: 8px; margin: 8px 0; font-size: 13px; }
5000
+ .badge-img { background: #fff; padding: 8px; border-radius: 6px; display: inline-block; margin-top: 8px; }
5001
+ code { background: #1f2937; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
5002
+ details summary { cursor: pointer; padding: 8px 0; font-weight: 600; }
5003
+ footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 48px; }
5004
+ footer a { color: #60a5fa; text-decoration: none; }
5005
+ </style>
5006
+ </head>
5007
+ <body>
5008
+ <div class="container">
5009
+ <header>
5010
+ <h1>MCP Compliance Report</h1>
5011
+ <div class="muted">${esc(report.url)}</div>
5012
+ <div class="muted" style="margin-top:6px">Spec ${esc(report.specVersion)} \xB7 Tool v${esc(report.toolVersion)} \xB7 ${new Date(report.timestamp).toLocaleString()}</div>
5013
+ ${report.serverInfo.name ? `<div class="muted">Server: ${esc(report.serverInfo.name)}${report.serverInfo.version ? ` v${esc(report.serverInfo.version)}` : ""}</div>` : ""}
5014
+ </header>
5015
+
5016
+ <div class="grade-card">
5017
+ <div class="grade-letter">${esc(report.grade)}</div>
5018
+ <div class="grade-score">${report.score}%</div>
5019
+ <div class="grade-overall ${esc(report.overall)}">${esc(report.overall)}</div>
5020
+ <div class="muted" style="margin-top:12px">${report.summary.passed} / ${report.summary.total} tests passed \xB7 ${report.summary.requiredPassed} / ${report.summary.required} required</div>
5021
+ </div>
5022
+
5023
+ <div class="grid">
5024
+ ${CATEGORY_ORDER.filter((c) => report.categories[c] && report.categories[c].total > 0).map((c) => {
5025
+ const s = report.categories[c];
5026
+ const cls = s.passed === s.total ? "full" : s.passed > 0 ? "partial" : "empty";
5027
+ return `<div class="cat-card"><div class="cat-stat ${cls}">${s.passed}/${s.total}</div><div class="cat-label">${esc(CATEGORY_LABELS[c] || c)}</div></div>`;
5028
+ }).join("")}
5029
+ </div>
5030
+
5031
+ ${report.warnings.length ? `<div class="card"><h2>Warnings (${report.warnings.length})</h2>${report.warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join("")}</div>` : ""}
5032
+
5033
+ ${failed.length ? `<div class="card"><h2>Failed tests (${failed.length})</h2>
5034
+ <table><thead><tr><th>Status</th><th>Test</th><th>Details</th></tr></thead><tbody>
5035
+ ${failed.map(
5036
+ (t) => `<tr>
5037
+ <td class="status fail">FAIL</td>
5038
+ <td><div>${esc(t.name)} ${t.required ? '<span class="badge-tag">Required</span>' : ""}</div><div class="id">${esc(t.id)}</div></td>
5039
+ <td>${esc(t.details)}${t.specRef ? ` <a href="${esc(t.specRef)}" class="muted">[spec]</a>` : ""}</td>
5040
+ </tr>`
5041
+ ).join("")}
5042
+ </tbody></table></div>` : ""}
5043
+
5044
+ ${[...grouped.entries()].filter(([, tests]) => tests.length > 0).map(
5045
+ ([cat, tests]) => `<div class="card"><h2>${esc(CATEGORY_LABELS[cat] || cat)}</h2>
5046
+ <table><thead><tr><th>Status</th><th>Test</th><th>Details</th><th>Time</th></tr></thead><tbody>
5047
+ ${tests.map(
5048
+ (t) => `<tr>
5049
+ <td class="status ${t.passed ? "pass" : "fail"}">${t.passed ? "PASS" : "FAIL"}</td>
5050
+ <td><div>${esc(t.name)} ${t.required ? '<span class="badge-tag">Required</span>' : ""}</div><div class="id">${esc(t.id)}</div></td>
5051
+ <td>${esc(t.details)}${t.specRef ? ` <a href="${esc(t.specRef)}" class="muted">[spec]</a>` : ""}</td>
5052
+ <td class="muted">${t.durationMs}ms</td>
5053
+ </tr>`
5054
+ ).join("")}
5055
+ </tbody></table></div>`
5056
+ ).join("")}
5057
+
5058
+ ${!isStdio ? `<div class="card"><h2>Embed badge</h2>
5059
+ <div class="badge-img"><img src="${esc(report.badge.imageUrl)}" alt="MCP Compliance"></div>
5060
+ <p class="muted" style="margin-top:12px">Markdown:</p>
5061
+ <code style="display:block; padding:8px; background:#0b0f17">${esc(report.badge.markdown)}</code></div>` : `<div class="card"><h2>Local badge</h2>
5062
+ <p class="muted">Stdio servers can't be published to mcp.hosting (no public URL). Use <code>--output badge.svg</code> to write a local badge image.</p></div>`}
5063
+
5064
+ <footer>
5065
+ Generated by <a href="https://www.npmjs.com/package/@yawlabs/mcp-compliance">@yawlabs/mcp-compliance</a> v${esc(report.toolVersion)}
5066
+ </footer>
5067
+ </div>
5068
+ </body>
5069
+ </html>`;
5070
+ }
4613
5071
 
4614
5072
  // src/token-store.ts
4615
5073
  import { createHash as createHash2 } from "crypto";
@@ -4619,7 +5077,7 @@ import { dirname as dirname2, join as join3 } from "path";
4619
5077
  var STORE_DIR = join3(homedir(), ".mcp-compliance");
4620
5078
  var STORE_PATH = join3(STORE_DIR, "tokens.json");
4621
5079
  function hashUrl(url) {
4622
- return createHash2("sha256").update(url).digest("hex").slice(0, 12);
5080
+ return createHash2("sha256").update(url).digest("hex").slice(0, 24);
4623
5081
  }
4624
5082
  function readStore() {
4625
5083
  if (!existsSync3(STORE_PATH)) return {};
@@ -4794,7 +5252,7 @@ program.command("test").description("Run the full compliance test suite against
4794
5252
  "[target]",
4795
5253
  "Server URL, or command to spawn as a stdio server (optional when a config file defines 'target')"
4796
5254
  ).argument("[extraArgs...]", "Additional args passed to the stdio command").addOption(
4797
- new Option("--format <format>", "Output format").choices(["terminal", "json", "sarif", "github", "markdown"]).default("terminal")
5255
+ new Option("--format <format>", "Output format").choices(["terminal", "json", "sarif", "github", "markdown", "html"]).default("terminal")
4798
5256
  ).option("--config <path>", "Load options from a config file (default: mcp-compliance.config.json in cwd)").option("--output <file>", "Write a local SVG badge to the given path after the run (works with any transport)").option("--list", "Print the test IDs that would run given current filters, then exit (no connection)").addOption(
4799
5257
  new Option(
4800
5258
  "--transport <kind>",
@@ -4817,7 +5275,7 @@ program.command("test").description("Run the full compliance test suite against
4817
5275
  "--timeout <ms>",
4818
5276
  "Request timeout in milliseconds (bump to 30000+ for stdio servers with slow startup)",
4819
5277
  "15000"
4820
- ).option("--no-color", "Disable colored output (also honors NO_COLOR env var)").option("--preflight-timeout <ms>", "Preflight connectivity check timeout in milliseconds").option("--retries <n>", "Number of retries for failed tests", "0").option(
5278
+ ).option("--no-color", "Disable colored output (also honors NO_COLOR env var)").option("--watch", "Re-run tests when files in the cwd change (stdio targets only)").option("--preflight-timeout <ms>", "Preflight connectivity check timeout in milliseconds").option("--retries <n>", "Number of retries for failed tests", "0").option(
4821
5279
  "--only <items>",
4822
5280
  'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
4823
5281
  parseList
@@ -4862,48 +5320,92 @@ ${defs.length} tests would run for transport=${transportKind}`));
4862
5320
  },
4863
5321
  config
4864
5322
  );
4865
- if (opts.format === "terminal") {
4866
- console.log(chalk2.dim(`
4867
- Testing ${describeTarget(transportTarget)}...
4868
- `));
4869
- }
4870
5323
  const only = opts.only ?? config?.only;
4871
5324
  const skip = opts.skip ?? config?.skip;
4872
5325
  const verbose = opts.verbose ?? config?.verbose;
4873
- const report = await runComplianceSuite(transportTarget, {
4874
- timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
4875
- preflightTimeout: opts.preflightTimeout ? parsePositiveInt(opts.preflightTimeout, "--preflight-timeout", 1) : config?.preflightTimeout,
4876
- retries: parsePositiveInt(opts.retries, "--retries"),
4877
- only,
4878
- skip,
4879
- onProgress: verbose ? (testId, passed, details) => {
4880
- const icon = passed ? chalk2.green("PASS") : chalk2.red("FAIL");
4881
- console.log(` ${icon} ${testId} \u2014 ${details}`);
4882
- } : void 0
4883
- });
4884
- if (verbose && opts.format === "terminal") {
4885
- console.log("");
4886
- }
4887
- if (opts.format === "json") {
4888
- console.log(formatJson(report));
4889
- } else if (opts.format === "sarif") {
4890
- console.log(formatSarif(report));
4891
- } else if (opts.format === "github") {
4892
- console.log(formatGithub(report));
4893
- } else if (opts.format === "markdown") {
4894
- console.log(formatMarkdown(report));
4895
- } else {
4896
- console.log(formatTerminal(report));
4897
- }
4898
- if (opts.output) {
4899
- const svg = renderBadgeSvg({ grade: report.grade, score: report.score, timestamp: report.timestamp });
4900
- writeFileSync2(opts.output, svg, "utf8");
5326
+ const strict = opts.strict ?? config?.strict;
5327
+ async function runOnce() {
4901
5328
  if (opts.format === "terminal") {
4902
5329
  console.log(chalk2.dim(`
5330
+ Testing ${describeTarget(transportTarget)}...
5331
+ `));
5332
+ }
5333
+ const report2 = await runComplianceSuite(transportTarget, {
5334
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
5335
+ preflightTimeout: opts.preflightTimeout ? parsePositiveInt(opts.preflightTimeout, "--preflight-timeout", 1) : config?.preflightTimeout,
5336
+ retries: parsePositiveInt(opts.retries, "--retries"),
5337
+ only,
5338
+ skip,
5339
+ onProgress: verbose ? (testId, passed, details) => {
5340
+ const icon = passed ? chalk2.green("PASS") : chalk2.red("FAIL");
5341
+ console.log(` ${icon} ${testId} \u2014 ${details}`);
5342
+ } : void 0
5343
+ });
5344
+ if (verbose && opts.format === "terminal") {
5345
+ console.log("");
5346
+ }
5347
+ if (opts.format === "json") {
5348
+ console.log(formatJson(report2));
5349
+ } else if (opts.format === "sarif") {
5350
+ console.log(formatSarif(report2));
5351
+ } else if (opts.format === "github") {
5352
+ console.log(formatGithub(report2));
5353
+ } else if (opts.format === "markdown") {
5354
+ console.log(formatMarkdown(report2));
5355
+ } else if (opts.format === "html") {
5356
+ console.log(formatHtml(report2));
5357
+ } else {
5358
+ console.log(formatTerminal(report2));
5359
+ }
5360
+ if (opts.output) {
5361
+ const svg = renderBadgeSvg({ grade: report2.grade, score: report2.score, timestamp: report2.timestamp });
5362
+ writeFileSync2(opts.output, svg, "utf8");
5363
+ if (opts.format === "terminal") {
5364
+ console.log(chalk2.dim(`
4903
5365
  Badge SVG written to ${opts.output}`));
5366
+ }
4904
5367
  }
5368
+ return report2;
4905
5369
  }
4906
- const strict = opts.strict ?? config?.strict;
5370
+ if (opts.watch) {
5371
+ if (transportTarget.type !== "stdio") {
5372
+ console.error(chalk2.red("\nError: --watch only applies to stdio targets (HTTP servers are remote).\n"));
5373
+ process.exit(1);
5374
+ }
5375
+ await runOnce();
5376
+ let pending = null;
5377
+ let running = false;
5378
+ const watcher = fsWatch(process.cwd(), { recursive: true }, (_event, filename) => {
5379
+ if (!filename) return;
5380
+ const f = String(filename).replace(/\\/g, "/");
5381
+ if (/(^|\/)(node_modules|\.git|dist|coverage|\.next|\.cache|\.turbo)(\/|$)/.test(f)) return;
5382
+ if (/\.(log|swp|tmp)$|~$/.test(f)) return;
5383
+ if (pending) clearTimeout(pending);
5384
+ pending = setTimeout(async () => {
5385
+ if (running) return;
5386
+ running = true;
5387
+ try {
5388
+ console.log(chalk2.dim(`
5389
+ [watch] ${f} changed \u2014 re-running...
5390
+ `));
5391
+ await runOnce();
5392
+ } catch (err) {
5393
+ console.error(chalk2.red(`[watch] ${err instanceof Error ? err.message : String(err)}`));
5394
+ } finally {
5395
+ running = false;
5396
+ }
5397
+ }, 500);
5398
+ });
5399
+ process.on("SIGINT", () => {
5400
+ watcher.close();
5401
+ console.log(chalk2.dim("\n[watch] stopped"));
5402
+ process.exit(0);
5403
+ });
5404
+ await new Promise(() => {
5405
+ });
5406
+ return;
5407
+ }
5408
+ const report = await runOnce();
4907
5409
  if (strict && report.overall === "fail") {
4908
5410
  process.exit(1);
4909
5411
  }
@@ -5058,6 +5560,55 @@ Error: ${message}
5058
5560
  process.exit(1);
5059
5561
  }
5060
5562
  });
5563
+ program.command("benchmark").description("Measure ping latency and throughput against an MCP server (URL or stdio command)").argument("[target]", "Server URL or stdio command").argument("[extraArgs...]", "Additional args for stdio command").option("-r, --requests <n>", "Number of ping requests to send", "100").option("-c, --concurrency <n>", "Concurrent in-flight requests", "1").option("--timeout <ms>", "Per-request timeout in milliseconds", "15000").option("--config <path>", "Load options from a config file").option("--format <format>", "terminal or json", "terminal").option("-H, --header <header>", "HTTP header (repeatable)", parseHeaderArg, {}).option("--auth <token>", 'Shorthand for -H "Authorization: <token>"').option("-E, --env <var>", "Env var for stdio (repeatable)", parseEnvVar, {}).option("--env-file <path>", "Load env vars from file").option("--cwd <dir>", "Working directory for stdio command").action(
5564
+ async (target, extraArgs, opts) => {
5565
+ try {
5566
+ const config = loadConfig(opts.config);
5567
+ const t = resolveTarget(
5568
+ target,
5569
+ extraArgs,
5570
+ { header: opts.header, auth: opts.auth, env: opts.env, envFile: opts.envFile, cwd: opts.cwd },
5571
+ config
5572
+ );
5573
+ const result = await runBenchmark(t, {
5574
+ requests: parsePositiveInt(opts.requests, "--requests", 1),
5575
+ concurrency: parsePositiveInt(opts.concurrency, "--concurrency", 1),
5576
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1)
5577
+ });
5578
+ if (opts.format === "json") {
5579
+ console.log(JSON.stringify(result, null, 2));
5580
+ } else {
5581
+ console.log(formatBenchmark(result));
5582
+ }
5583
+ if (result.failed > 0) process.exit(1);
5584
+ } catch (err) {
5585
+ const message = err instanceof Error ? err.message : String(err);
5586
+ console.error(chalk2.red(`
5587
+ Error: ${message}
5588
+ `));
5589
+ process.exit(1);
5590
+ }
5591
+ }
5592
+ );
5593
+ program.command("diff").description("Compare two compliance JSON reports; exit 1 if there are regressions").argument("<baseline>", "Baseline report JSON file").argument("<current>", "Current report JSON file").option("--format <format>", "terminal or json", "terminal").action((baselinePath, currentPath, opts) => {
5594
+ try {
5595
+ const baseline = JSON.parse(readFileSync4(baselinePath, "utf8"));
5596
+ const current = JSON.parse(readFileSync4(currentPath, "utf8"));
5597
+ const summary = diffReports(baseline, current);
5598
+ if (opts.format === "json") {
5599
+ console.log(JSON.stringify(summary, null, 2));
5600
+ } else {
5601
+ console.log(formatDiff(summary));
5602
+ }
5603
+ if (hasRegressions(summary)) process.exit(1);
5604
+ } catch (err) {
5605
+ const message = err instanceof Error ? err.message : String(err);
5606
+ console.error(chalk2.red(`
5607
+ Error: ${message}
5608
+ `));
5609
+ process.exit(1);
5610
+ }
5611
+ });
5061
5612
  program.command("init").description("Scaffold a mcp-compliance.config.json in the current directory").option("--force", "Overwrite an existing config file without asking").action(async (opts) => {
5062
5613
  const { existsSync: existsSync4, writeFileSync: write } = await import("fs");
5063
5614
  const { join: joinPath } = await import("path");