@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/README.md +77 -3
- package/dist/{chunk-FKTEFLK5.js → chunk-DGGPE3ZM.js} +133 -4
- package/dist/index.js +733 -182
- package/dist/mcp/server.js +1 -1
- package/dist/runner.d.ts +21 -2
- package/dist/runner.js +5 -3
- package/package.json +4 -1
- package/schemas/report.v1.json +165 -0
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/
|
|
60
|
-
import {
|
|
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: `[](${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: `[](${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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,
|
|
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
|
|
4874
|
-
|
|
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
|
-
|
|
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");
|