@yawlabs/mcp-compliance 0.9.2 → 0.10.1

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,15 @@ 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-meta-tolerance",
1052
+ name: "Tolerates _meta field on requests",
1053
+ category: "lifecycle",
1054
+ required: false,
1055
+ specRef: "basic/utilities#_meta",
1056
+ 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.",
1057
+ 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."
1058
+ },
826
1059
  // ── Tools (4 tests) ──────────────────────────────────────────────
827
1060
  {
828
1061
  id: "tools-list",
@@ -1535,7 +1768,7 @@ async function runComplianceSuite(target, options = {}) {
1535
1768
  if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1536
1769
  }
1537
1770
  }
1538
- tests.push({
1771
+ const result = {
1539
1772
  id,
1540
1773
  name,
1541
1774
  category,
@@ -1544,8 +1777,10 @@ async function runComplianceSuite(target, options = {}) {
1544
1777
  details: lastResult.details,
1545
1778
  durationMs: Date.now() - start,
1546
1779
  specRef: `${SPEC_BASE}/${specRef}`
1547
- });
1780
+ };
1781
+ tests.push(result);
1548
1782
  options.onProgress?.(id, lastResult.passed, lastResult.details);
1783
+ options.onTestComplete?.(result);
1549
1784
  }
1550
1785
  await test(
1551
1786
  "transport-post",
@@ -2137,6 +2372,28 @@ async function runComplianceSuite(target, options = {}) {
2137
2372
  }
2138
2373
  }
2139
2374
  );
2375
+ await test(
2376
+ "lifecycle-meta-tolerance",
2377
+ "Tolerates _meta field on requests",
2378
+ "lifecycle",
2379
+ false,
2380
+ "basic/utilities#_meta",
2381
+ async () => {
2382
+ try {
2383
+ const res = await rpc("ping", { _meta: { "mcp-compliance/probe": "1" } });
2384
+ const body = res.body;
2385
+ if (body.error) {
2386
+ return {
2387
+ passed: false,
2388
+ details: `Server rejected _meta on ping (code ${body.error.code}). _meta should be ignored, not error.`
2389
+ };
2390
+ }
2391
+ return { passed: true, details: "Server accepted ping with arbitrary _meta field" };
2392
+ } catch (err) {
2393
+ return { passed: false, details: `Error: ${err instanceof Error ? err.message : String(err)}` };
2394
+ }
2395
+ }
2396
+ );
2140
2397
  await test(
2141
2398
  "transport-content-type-init",
2142
2399
  "Initialize response has valid content type",
@@ -4019,6 +4276,7 @@ async function runComplianceSuite(target, options = {}) {
4019
4276
  const { score, grade, overall, summary, categories } = computeScore(tests);
4020
4277
  const badge = generateBadge(displayUrl);
4021
4278
  return {
4279
+ schemaVersion: REPORT_SCHEMA_VERSION,
4022
4280
  specVersion: SPEC_VERSION,
4023
4281
  toolVersion: TOOL_VERSION,
4024
4282
  url: displayUrl,
@@ -4610,6 +4868,134 @@ function formatMarkdown(report) {
4610
4868
  }
4611
4869
  return lines.join("\n");
4612
4870
  }
4871
+ function formatHtml(report) {
4872
+ const gradeColors = {
4873
+ A: "#10b981",
4874
+ B: "#84cc16",
4875
+ C: "#eab308",
4876
+ D: "#f97316",
4877
+ F: "#ef4444"
4878
+ };
4879
+ const gradeColor2 = gradeColors[report.grade] || "#6b7280";
4880
+ function esc(s) {
4881
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4882
+ }
4883
+ const failed = report.tests.filter((t) => !t.passed);
4884
+ const grouped = /* @__PURE__ */ new Map();
4885
+ for (const cat of CATEGORY_ORDER) grouped.set(cat, []);
4886
+ for (const t of report.tests) grouped.get(t.category)?.push(t);
4887
+ const isStdio = report.url.startsWith("stdio:");
4888
+ return `<!doctype html>
4889
+ <html lang="en">
4890
+ <head>
4891
+ <meta charset="utf-8">
4892
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4893
+ <title>MCP Compliance \u2014 ${esc(report.url)} \u2014 Grade ${report.grade}</title>
4894
+ <style>
4895
+ :root { color-scheme: light dark; }
4896
+ *, *::before, *::after { box-sizing: border-box; }
4897
+ body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: #0b0f17; color: #e5e7eb; }
4898
+ @media (prefers-color-scheme: light) { body { background: #f9fafb; color: #111827; } .card { background: #fff !important; border-color: #e5e7eb !important; } .muted { color: #6b7280 !important; } }
4899
+ .container { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
4900
+ header { text-align: center; margin-bottom: 32px; }
4901
+ h1 { font-size: 28px; margin: 0 0 4px; }
4902
+ .muted { color: #9ca3af; font-size: 13px; }
4903
+ .grade-card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 32px; margin: 24px 0; text-align: center; }
4904
+ .grade-letter { font-size: 96px; font-weight: 700; line-height: 1; color: ${gradeColor2}; margin: 0; }
4905
+ .grade-score { font-size: 24px; font-weight: 600; margin-top: 4px; }
4906
+ .grade-overall { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; text-transform: uppercase; margin-top: 12px; }
4907
+ .grade-overall.pass { background: #064e3b; color: #6ee7b7; }
4908
+ .grade-overall.partial { background: #78350f; color: #fcd34d; }
4909
+ .grade-overall.fail { background: #7f1d1d; color: #fca5a5; }
4910
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 24px 0; }
4911
+ .cat-card { background: #111827; border: 1px solid #1f2937; border-radius: 8px; padding: 16px; text-align: center; }
4912
+ .cat-stat { font-size: 24px; font-weight: 700; }
4913
+ .cat-label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; }
4914
+ .cat-stat.full { color: #10b981; }
4915
+ .cat-stat.partial { color: #eab308; }
4916
+ .cat-stat.empty { color: #ef4444; }
4917
+ .card { background: #111827; border: 1px solid #1f2937; border-radius: 8px; padding: 20px; margin: 16px 0; }
4918
+ .card h2 { margin-top: 0; font-size: 16px; }
4919
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
4920
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #1f2937; vertical-align: top; }
4921
+ th { font-weight: 600; color: #9ca3af; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
4922
+ td.status { white-space: nowrap; font-weight: 600; }
4923
+ td.status.pass { color: #10b981; }
4924
+ td.status.fail { color: #ef4444; }
4925
+ td.id { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: #9ca3af; }
4926
+ .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; }
4927
+ .warn { background: #78350f; color: #fcd34d; padding: 12px 16px; border-radius: 8px; margin: 8px 0; font-size: 13px; }
4928
+ .badge-img { background: #fff; padding: 8px; border-radius: 6px; display: inline-block; margin-top: 8px; }
4929
+ code { background: #1f2937; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
4930
+ details summary { cursor: pointer; padding: 8px 0; font-weight: 600; }
4931
+ footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 48px; }
4932
+ footer a { color: #60a5fa; text-decoration: none; }
4933
+ </style>
4934
+ </head>
4935
+ <body>
4936
+ <div class="container">
4937
+ <header>
4938
+ <h1>MCP Compliance Report</h1>
4939
+ <div class="muted">${esc(report.url)}</div>
4940
+ <div class="muted" style="margin-top:6px">Spec ${esc(report.specVersion)} \xB7 Tool v${esc(report.toolVersion)} \xB7 ${new Date(report.timestamp).toLocaleString()}</div>
4941
+ ${report.serverInfo.name ? `<div class="muted">Server: ${esc(report.serverInfo.name)}${report.serverInfo.version ? ` v${esc(report.serverInfo.version)}` : ""}</div>` : ""}
4942
+ </header>
4943
+
4944
+ <div class="grade-card">
4945
+ <div class="grade-letter">${esc(report.grade)}</div>
4946
+ <div class="grade-score">${report.score}%</div>
4947
+ <div class="grade-overall ${esc(report.overall)}">${esc(report.overall)}</div>
4948
+ <div class="muted" style="margin-top:12px">${report.summary.passed} / ${report.summary.total} tests passed \xB7 ${report.summary.requiredPassed} / ${report.summary.required} required</div>
4949
+ </div>
4950
+
4951
+ <div class="grid">
4952
+ ${CATEGORY_ORDER.filter((c) => report.categories[c] && report.categories[c].total > 0).map((c) => {
4953
+ const s = report.categories[c];
4954
+ const cls = s.passed === s.total ? "full" : s.passed > 0 ? "partial" : "empty";
4955
+ 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>`;
4956
+ }).join("")}
4957
+ </div>
4958
+
4959
+ ${report.warnings.length ? `<div class="card"><h2>Warnings (${report.warnings.length})</h2>${report.warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join("")}</div>` : ""}
4960
+
4961
+ ${failed.length ? `<div class="card"><h2>Failed tests (${failed.length})</h2>
4962
+ <table><thead><tr><th>Status</th><th>Test</th><th>Details</th></tr></thead><tbody>
4963
+ ${failed.map(
4964
+ (t) => `<tr>
4965
+ <td class="status fail">FAIL</td>
4966
+ <td><div>${esc(t.name)} ${t.required ? '<span class="badge-tag">Required</span>' : ""}</div><div class="id">${esc(t.id)}</div></td>
4967
+ <td>${esc(t.details)}${t.specRef ? ` <a href="${esc(t.specRef)}" class="muted">[spec]</a>` : ""}</td>
4968
+ </tr>`
4969
+ ).join("")}
4970
+ </tbody></table></div>` : ""}
4971
+
4972
+ ${[...grouped.entries()].filter(([, tests]) => tests.length > 0).map(
4973
+ ([cat, tests]) => `<div class="card"><h2>${esc(CATEGORY_LABELS[cat] || cat)}</h2>
4974
+ <table><thead><tr><th>Status</th><th>Test</th><th>Details</th><th>Time</th></tr></thead><tbody>
4975
+ ${tests.map(
4976
+ (t) => `<tr>
4977
+ <td class="status ${t.passed ? "pass" : "fail"}">${t.passed ? "PASS" : "FAIL"}</td>
4978
+ <td><div>${esc(t.name)} ${t.required ? '<span class="badge-tag">Required</span>' : ""}</div><div class="id">${esc(t.id)}</div></td>
4979
+ <td>${esc(t.details)}${t.specRef ? ` <a href="${esc(t.specRef)}" class="muted">[spec]</a>` : ""}</td>
4980
+ <td class="muted">${t.durationMs}ms</td>
4981
+ </tr>`
4982
+ ).join("")}
4983
+ </tbody></table></div>`
4984
+ ).join("")}
4985
+
4986
+ ${!isStdio ? `<div class="card"><h2>Embed badge</h2>
4987
+ <div class="badge-img"><img src="${esc(report.badge.imageUrl)}" alt="MCP Compliance"></div>
4988
+ <p class="muted" style="margin-top:12px">Markdown:</p>
4989
+ <code style="display:block; padding:8px; background:#0b0f17">${esc(report.badge.markdown)}</code></div>` : `<div class="card"><h2>Local badge</h2>
4990
+ <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>`}
4991
+
4992
+ <footer>
4993
+ Generated by <a href="https://www.npmjs.com/package/@yawlabs/mcp-compliance">@yawlabs/mcp-compliance</a> v${esc(report.toolVersion)}
4994
+ </footer>
4995
+ </div>
4996
+ </body>
4997
+ </html>`;
4998
+ }
4613
4999
 
4614
5000
  // src/token-store.ts
4615
5001
  import { createHash as createHash2 } from "crypto";
@@ -4619,7 +5005,7 @@ import { dirname as dirname2, join as join3 } from "path";
4619
5005
  var STORE_DIR = join3(homedir(), ".mcp-compliance");
4620
5006
  var STORE_PATH = join3(STORE_DIR, "tokens.json");
4621
5007
  function hashUrl(url) {
4622
- return createHash2("sha256").update(url).digest("hex").slice(0, 12);
5008
+ return createHash2("sha256").update(url).digest("hex").slice(0, 24);
4623
5009
  }
4624
5010
  function readStore() {
4625
5011
  if (!existsSync3(STORE_PATH)) return {};
@@ -4794,7 +5180,7 @@ program.command("test").description("Run the full compliance test suite against
4794
5180
  "[target]",
4795
5181
  "Server URL, or command to spawn as a stdio server (optional when a config file defines 'target')"
4796
5182
  ).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")
5183
+ new Option("--format <format>", "Output format").choices(["terminal", "json", "sarif", "github", "markdown", "html"]).default("terminal")
4798
5184
  ).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
5185
  new Option(
4800
5186
  "--transport <kind>",
@@ -4817,7 +5203,7 @@ program.command("test").description("Run the full compliance test suite against
4817
5203
  "--timeout <ms>",
4818
5204
  "Request timeout in milliseconds (bump to 30000+ for stdio servers with slow startup)",
4819
5205
  "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(
5206
+ ).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
5207
  "--only <items>",
4822
5208
  'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
4823
5209
  parseList
@@ -4862,48 +5248,92 @@ ${defs.length} tests would run for transport=${transportKind}`));
4862
5248
  },
4863
5249
  config
4864
5250
  );
4865
- if (opts.format === "terminal") {
4866
- console.log(chalk2.dim(`
4867
- Testing ${describeTarget(transportTarget)}...
4868
- `));
4869
- }
4870
5251
  const only = opts.only ?? config?.only;
4871
5252
  const skip = opts.skip ?? config?.skip;
4872
5253
  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");
5254
+ const strict = opts.strict ?? config?.strict;
5255
+ async function runOnce() {
4901
5256
  if (opts.format === "terminal") {
4902
5257
  console.log(chalk2.dim(`
5258
+ Testing ${describeTarget(transportTarget)}...
5259
+ `));
5260
+ }
5261
+ const report2 = await runComplianceSuite(transportTarget, {
5262
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
5263
+ preflightTimeout: opts.preflightTimeout ? parsePositiveInt(opts.preflightTimeout, "--preflight-timeout", 1) : config?.preflightTimeout,
5264
+ retries: parsePositiveInt(opts.retries, "--retries"),
5265
+ only,
5266
+ skip,
5267
+ onProgress: verbose ? (testId, passed, details) => {
5268
+ const icon = passed ? chalk2.green("PASS") : chalk2.red("FAIL");
5269
+ console.log(` ${icon} ${testId} \u2014 ${details}`);
5270
+ } : void 0
5271
+ });
5272
+ if (verbose && opts.format === "terminal") {
5273
+ console.log("");
5274
+ }
5275
+ if (opts.format === "json") {
5276
+ console.log(formatJson(report2));
5277
+ } else if (opts.format === "sarif") {
5278
+ console.log(formatSarif(report2));
5279
+ } else if (opts.format === "github") {
5280
+ console.log(formatGithub(report2));
5281
+ } else if (opts.format === "markdown") {
5282
+ console.log(formatMarkdown(report2));
5283
+ } else if (opts.format === "html") {
5284
+ console.log(formatHtml(report2));
5285
+ } else {
5286
+ console.log(formatTerminal(report2));
5287
+ }
5288
+ if (opts.output) {
5289
+ const svg = renderBadgeSvg({ grade: report2.grade, score: report2.score, timestamp: report2.timestamp });
5290
+ writeFileSync2(opts.output, svg, "utf8");
5291
+ if (opts.format === "terminal") {
5292
+ console.log(chalk2.dim(`
4903
5293
  Badge SVG written to ${opts.output}`));
5294
+ }
4904
5295
  }
5296
+ return report2;
4905
5297
  }
4906
- const strict = opts.strict ?? config?.strict;
5298
+ if (opts.watch) {
5299
+ if (transportTarget.type !== "stdio") {
5300
+ console.error(chalk2.red("\nError: --watch only applies to stdio targets (HTTP servers are remote).\n"));
5301
+ process.exit(1);
5302
+ }
5303
+ await runOnce();
5304
+ let pending = null;
5305
+ let running = false;
5306
+ const watcher = fsWatch(process.cwd(), { recursive: true }, (_event, filename) => {
5307
+ if (!filename) return;
5308
+ const f = String(filename).replace(/\\/g, "/");
5309
+ if (/(^|\/)(node_modules|\.git|dist|coverage|\.next|\.cache|\.turbo)(\/|$)/.test(f)) return;
5310
+ if (/\.(log|swp|tmp)$|~$/.test(f)) return;
5311
+ if (pending) clearTimeout(pending);
5312
+ pending = setTimeout(async () => {
5313
+ if (running) return;
5314
+ running = true;
5315
+ try {
5316
+ console.log(chalk2.dim(`
5317
+ [watch] ${f} changed \u2014 re-running...
5318
+ `));
5319
+ await runOnce();
5320
+ } catch (err) {
5321
+ console.error(chalk2.red(`[watch] ${err instanceof Error ? err.message : String(err)}`));
5322
+ } finally {
5323
+ running = false;
5324
+ }
5325
+ }, 500);
5326
+ });
5327
+ process.on("SIGINT", () => {
5328
+ watcher.close();
5329
+ console.log(chalk2.dim("\n[watch] stopped"));
5330
+ process.exit(0);
5331
+ });
5332
+ await new Promise(() => {
5333
+ });
5334
+ return;
5335
+ }
5336
+ const report = await runOnce();
4907
5337
  if (strict && report.overall === "fail") {
4908
5338
  process.exit(1);
4909
5339
  }
@@ -5058,6 +5488,55 @@ Error: ${message}
5058
5488
  process.exit(1);
5059
5489
  }
5060
5490
  });
5491
+ 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(
5492
+ async (target, extraArgs, opts) => {
5493
+ try {
5494
+ const config = loadConfig(opts.config);
5495
+ const t = resolveTarget(
5496
+ target,
5497
+ extraArgs,
5498
+ { header: opts.header, auth: opts.auth, env: opts.env, envFile: opts.envFile, cwd: opts.cwd },
5499
+ config
5500
+ );
5501
+ const result = await runBenchmark(t, {
5502
+ requests: parsePositiveInt(opts.requests, "--requests", 1),
5503
+ concurrency: parsePositiveInt(opts.concurrency, "--concurrency", 1),
5504
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1)
5505
+ });
5506
+ if (opts.format === "json") {
5507
+ console.log(JSON.stringify(result, null, 2));
5508
+ } else {
5509
+ console.log(formatBenchmark(result));
5510
+ }
5511
+ if (result.failed > 0) process.exit(1);
5512
+ } catch (err) {
5513
+ const message = err instanceof Error ? err.message : String(err);
5514
+ console.error(chalk2.red(`
5515
+ Error: ${message}
5516
+ `));
5517
+ process.exit(1);
5518
+ }
5519
+ }
5520
+ );
5521
+ 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) => {
5522
+ try {
5523
+ const baseline = JSON.parse(readFileSync4(baselinePath, "utf8"));
5524
+ const current = JSON.parse(readFileSync4(currentPath, "utf8"));
5525
+ const summary = diffReports(baseline, current);
5526
+ if (opts.format === "json") {
5527
+ console.log(JSON.stringify(summary, null, 2));
5528
+ } else {
5529
+ console.log(formatDiff(summary));
5530
+ }
5531
+ if (hasRegressions(summary)) process.exit(1);
5532
+ } catch (err) {
5533
+ const message = err instanceof Error ? err.message : String(err);
5534
+ console.error(chalk2.red(`
5535
+ Error: ${message}
5536
+ `));
5537
+ process.exit(1);
5538
+ }
5539
+ });
5061
5540
  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
5541
  const { existsSync: existsSync4, writeFileSync: write } = await import("fs");
5063
5542
  const { join: joinPath } = await import("path");