@yawlabs/mcp-compliance 0.14.3 → 0.15.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 CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
12
- [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](yaw://install?name=mcp-compliance&command=npx&args=-y%2C%40yawlabs%2Fmcp-compliance&description=Test%20any%20MCP%20server%20against%20the%20spec%20-%2088-test%20suite%20with%20letter-grade%20scoring&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fmcp-compliance)
12
+ [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](https://yaw.sh/mcp/install?name=mcp-compliance&command=npx&args=-y%2C%40yawlabs%2Fmcp-compliance&description=Test%20any%20MCP%20server%20against%20the%20spec%20-%2088-test%20suite%20with%20letter-grade%20scoring&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fmcp-compliance)
13
13
 
14
14
  One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
15
15
 
@@ -1,5 +1,4 @@
1
1
  // src/runner.ts
2
- import { createRequire } from "module";
3
2
  import { request as request2 } from "undici";
4
3
 
5
4
  // src/badge.ts
@@ -74,6 +73,27 @@ function computeScore(tests) {
74
73
  };
75
74
  }
76
75
 
76
+ // src/pkg-version.ts
77
+ import { existsSync, readFileSync } from "fs";
78
+ import { dirname, join } from "path";
79
+ import { fileURLToPath } from "url";
80
+ function readPackageVersion(metaUrl) {
81
+ let dir = dirname(fileURLToPath(metaUrl));
82
+ for (; ; ) {
83
+ const pkgPath = join(dir, "package.json");
84
+ if (existsSync(pkgPath)) {
85
+ try {
86
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
87
+ } catch {
88
+ return "0.0.0";
89
+ }
90
+ }
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return "0.0.0";
93
+ dir = parent;
94
+ }
95
+ }
96
+
77
97
  // src/transport/http.ts
78
98
  import { request } from "undici";
79
99
 
@@ -127,10 +147,17 @@ function createHttpTransport(opts) {
127
147
  }
128
148
  return out;
129
149
  }
130
- async function doRawRequest(method, body, extraHeaders, timeout) {
150
+ async function doRawRequest(method, body, extraHeaders, timeout, omitUserHeaders) {
151
+ const base = sessionHeaders();
152
+ if (omitUserHeaders && omitUserHeaders.length > 0) {
153
+ const drop = new Set(omitUserHeaders.map((h) => h.toLowerCase()));
154
+ for (const key of Object.keys(base)) {
155
+ if (drop.has(key.toLowerCase())) delete base[key];
156
+ }
157
+ }
131
158
  const headers = {
132
159
  Accept: "application/json, text/event-stream",
133
- ...sessionHeaders(),
160
+ ...base,
134
161
  ...extraHeaders
135
162
  };
136
163
  if (body !== void 0 && !("Content-Type" in headers) && !("content-type" in headers)) {
@@ -155,7 +182,7 @@ function createHttpTransport(opts) {
155
182
  async request(method, params, nextId, init) {
156
183
  const id = nextId();
157
184
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
158
- const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
185
+ const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout, init.omitUserHeaders);
159
186
  const contentType = (raw.headers["content-type"] || "").toLowerCase();
160
187
  let parsed;
161
188
  if (contentType.includes("text/event-stream")) {
@@ -185,7 +212,7 @@ function createHttpTransport(opts) {
185
212
  },
186
213
  async notify(method, params, init) {
187
214
  const body = JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} });
188
- const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
215
+ const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout, init.omitUserHeaders);
189
216
  return { statusCode: raw.statusCode, headers: raw.headers };
190
217
  },
191
218
  async close() {
@@ -392,23 +419,30 @@ function createStdioTransport(opts) {
392
419
  child.stdin?.end();
393
420
  } catch {
394
421
  }
395
- const gracePeriodMs = 2e3;
396
- await new Promise((resolve) => {
397
- const timer = setTimeout(() => {
422
+ const treeKill = (force) => {
423
+ if (isWindows && child.pid !== void 0) {
424
+ try {
425
+ spawn("taskkill", ["/pid", String(child.pid), "/t", ...force ? ["/f"] : []], { stdio: "ignore" });
426
+ } catch {
427
+ }
428
+ } else {
398
429
  try {
399
- child.kill("SIGKILL");
430
+ child.kill(force ? "SIGKILL" : "SIGTERM");
400
431
  } catch {
401
432
  }
433
+ }
434
+ };
435
+ const gracePeriodMs = 2e3;
436
+ await new Promise((resolve) => {
437
+ const timer = setTimeout(() => {
438
+ treeKill(true);
402
439
  resolve();
403
440
  }, gracePeriodMs);
404
441
  child.once("exit", () => {
405
442
  clearTimeout(timer);
406
443
  resolve();
407
444
  });
408
- try {
409
- child.kill(isWindows ? void 0 : "SIGTERM");
410
- } catch {
411
- }
445
+ treeKill(false);
412
446
  });
413
447
  rejectAllPending(new Error("stdio transport: closed"));
414
448
  },
@@ -1247,8 +1281,7 @@ var TEST_DEFINITIONS = [
1247
1281
 
1248
1282
  // src/runner.ts
1249
1283
  var TEST_DEFINITIONS_MAP = new Map(TEST_DEFINITIONS.map((t) => [t.id, t]));
1250
- var _require = createRequire(import.meta.url);
1251
- var { version: TOOL_VERSION } = _require("../package.json");
1284
+ var TOOL_VERSION = readPackageVersion(import.meta.url);
1252
1285
  var SPEC_VERSION = "2025-11-25";
1253
1286
  var SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`;
1254
1287
  var VALID_CONTENT_TYPES = ["text", "image", "audio", "resource", "resource_link"];
@@ -1451,10 +1484,11 @@ async function runComplianceSuite(target, options = {}) {
1451
1484
  const retries = options.retries || 0;
1452
1485
  let sessionId = null;
1453
1486
  let negotiatedProtocolVersion = null;
1454
- async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs) {
1487
+ async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs, omitUserHeaders) {
1455
1488
  const res = await transport.request(method, params, idCounter, {
1456
1489
  timeout: timeoutMs,
1457
- headers: extraHeaders
1490
+ headers: extraHeaders,
1491
+ omitUserHeaders
1458
1492
  });
1459
1493
  return {
1460
1494
  statusCode: res.statusCode ?? 200,
@@ -2467,7 +2501,7 @@ async function runComplianceSuite(target, options = {}) {
2467
2501
  issues.push("Tool missing name");
2468
2502
  continue;
2469
2503
  }
2470
- if (tool.name.length > 128 || !/^[A-Za-z0-9_.\-]+$/.test(tool.name)) {
2504
+ if (tool.name.length > 128 || !/^[A-Za-z0-9_.-]+$/.test(tool.name)) {
2471
2505
  issues.push(`${tool.name}: name format invalid`);
2472
2506
  }
2473
2507
  if (!tool.description) warnings.push(`Tool "${tool.name}" missing description`);
@@ -3168,7 +3202,9 @@ async function runComplianceSuite(target, options = {}) {
3168
3202
  const noAuthHeaders = {};
3169
3203
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
3170
3204
  try {
3171
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
3205
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout, [
3206
+ "authorization"
3207
+ ]);
3172
3208
  if (res.statusCode === 401 || res.statusCode === 403) {
3173
3209
  return { passed: true, details: `HTTP ${res.statusCode} (unauthenticated request rejected)` };
3174
3210
  }
@@ -3191,7 +3227,9 @@ async function runComplianceSuite(target, options = {}) {
3191
3227
  const noAuthHeaders = {};
3192
3228
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
3193
3229
  try {
3194
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
3230
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout, [
3231
+ "authorization"
3232
+ ]);
3195
3233
  if (res.statusCode === 401) {
3196
3234
  const wwwAuth = res.headers["www-authenticate"];
3197
3235
  if (wwwAuth) {
@@ -3219,14 +3257,16 @@ async function runComplianceSuite(target, options = {}) {
3219
3257
  "basic/authorization",
3220
3258
  async () => {
3221
3259
  if (!hasAuth) {
3222
- return { passed: false, details: "Skipped: server does not require auth" };
3260
+ return { passed: true, details: "Skipped: server does not require auth" };
3223
3261
  }
3224
3262
  const malformedHeaders = {
3225
3263
  Authorization: "Bearer INVALID_GARBAGE_TOKEN_!@#$%^&*()"
3226
3264
  };
3227
3265
  if (sessionId) malformedHeaders["mcp-session-id"] = sessionId;
3228
3266
  try {
3229
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout);
3267
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout, [
3268
+ "authorization"
3269
+ ]);
3230
3270
  if (res.statusCode === 401 || res.statusCode === 403) {
3231
3271
  return { passed: true, details: `HTTP ${res.statusCode} (malformed auth rejected)` };
3232
3272
  }
@@ -3316,7 +3356,9 @@ async function runComplianceSuite(target, options = {}) {
3316
3356
  "mcp-session-id": sessionId
3317
3357
  };
3318
3358
  try {
3319
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout);
3359
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout, [
3360
+ "authorization"
3361
+ ]);
3320
3362
  if (res.statusCode === 401 || res.statusCode === 403) {
3321
3363
  return { passed: true, details: `HTTP ${res.statusCode} (session ID alone not sufficient for auth)` };
3322
3364
  }
@@ -3717,9 +3759,10 @@ async function runComplianceSuite(target, options = {}) {
3717
3759
  return { passed: true, details: "No tools available to test (skipped)" };
3718
3760
  }
3719
3761
  try {
3762
+ const maliciousArgs = JSON.parse('{"__injected_param__":"malicious_value","__proto__":{"admin":true}}');
3720
3763
  const res = await rpc("tools/call", {
3721
3764
  name: toolNames[0],
3722
- arguments: { __injected_param__: "malicious_value", __proto__: { admin: true } }
3765
+ arguments: maliciousArgs
3723
3766
  });
3724
3767
  const error = res.body?.error;
3725
3768
  if (error) {
@@ -4123,6 +4166,7 @@ export {
4123
4166
  generateBadge,
4124
4167
  computeGrade,
4125
4168
  computeScore,
4169
+ readPackageVersion,
4126
4170
  parseSSEResponse,
4127
4171
  TEST_DEFINITIONS,
4128
4172
  SPEC_VERSION,
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { watch as fsWatch, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
5
- import { createRequire as createRequire2 } from "module";
5
+ import { createRequire } from "module";
6
6
  import { createInterface } from "readline/promises";
7
7
  import chalk2 from "chalk";
8
8
  import { Command, Option } from "commander";
@@ -117,10 +117,17 @@ function createHttpTransport(opts) {
117
117
  }
118
118
  return out;
119
119
  }
120
- async function doRawRequest(method, body, extraHeaders, timeout) {
120
+ async function doRawRequest(method, body, extraHeaders, timeout, omitUserHeaders) {
121
+ const base = sessionHeaders();
122
+ if (omitUserHeaders && omitUserHeaders.length > 0) {
123
+ const drop = new Set(omitUserHeaders.map((h) => h.toLowerCase()));
124
+ for (const key of Object.keys(base)) {
125
+ if (drop.has(key.toLowerCase())) delete base[key];
126
+ }
127
+ }
121
128
  const headers = {
122
129
  Accept: "application/json, text/event-stream",
123
- ...sessionHeaders(),
130
+ ...base,
124
131
  ...extraHeaders
125
132
  };
126
133
  if (body !== void 0 && !("Content-Type" in headers) && !("content-type" in headers)) {
@@ -145,7 +152,7 @@ function createHttpTransport(opts) {
145
152
  async request(method, params, nextId, init) {
146
153
  const id = nextId();
147
154
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
148
- const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
155
+ const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout, init.omitUserHeaders);
149
156
  const contentType = (raw.headers["content-type"] || "").toLowerCase();
150
157
  let parsed;
151
158
  if (contentType.includes("text/event-stream")) {
@@ -175,7 +182,7 @@ function createHttpTransport(opts) {
175
182
  },
176
183
  async notify(method, params, init) {
177
184
  const body = JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} });
178
- const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
185
+ const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout, init.omitUserHeaders);
179
186
  return { statusCode: raw.statusCode, headers: raw.headers };
180
187
  },
181
188
  async close() {
@@ -231,10 +238,10 @@ function createStdioTransport(opts) {
231
238
  const pending = /* @__PURE__ */ new Map();
232
239
  let stdoutBuffer = "";
233
240
  let stderrBuffer = "";
234
- const spawnReady = new Promise((resolve3, reject) => {
241
+ const spawnReady = new Promise((resolve2, reject) => {
235
242
  child.once("spawn", () => {
236
243
  spawned = true;
237
- resolve3();
244
+ resolve2();
238
245
  });
239
246
  child.once("error", (err) => {
240
247
  if (!spawned) reject(err);
@@ -330,9 +337,9 @@ function createStdioTransport(opts) {
330
337
  if (spawnError) throw new Error(annotateWithStderr(`stdio transport: spawn failed \u2014 ${spawnError.message}`));
331
338
  const stdin = child.stdin;
332
339
  if (!stdin || stdin.destroyed) throw new Error(annotateWithStderr("stdio transport: stdin is closed"));
333
- return new Promise((resolve3, reject) => {
340
+ return new Promise((resolve2, reject) => {
334
341
  stdin.write(`${line}
335
- `, "utf8", (err) => err ? reject(err) : resolve3());
342
+ `, "utf8", (err) => err ? reject(err) : resolve2());
336
343
  });
337
344
  }
338
345
  const transport = {
@@ -354,7 +361,7 @@ function createStdioTransport(opts) {
354
361
  async request(method, params, nextId, init) {
355
362
  const id = nextId();
356
363
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
357
- return new Promise((resolve3, reject) => {
364
+ return new Promise((resolve2, reject) => {
358
365
  const timer = setTimeout(() => {
359
366
  pending.delete(id);
360
367
  reject(
@@ -363,7 +370,7 @@ function createStdioTransport(opts) {
363
370
  )
364
371
  );
365
372
  }, init.timeout);
366
- pending.set(id, { resolve: resolve3, reject, id, timer });
373
+ pending.set(id, { resolve: resolve2, reject, id, timer });
367
374
  writeLine(body).catch((err) => {
368
375
  clearTimeout(timer);
369
376
  pending.delete(id);
@@ -382,23 +389,30 @@ function createStdioTransport(opts) {
382
389
  child.stdin?.end();
383
390
  } catch {
384
391
  }
385
- const gracePeriodMs = 2e3;
386
- await new Promise((resolve3) => {
387
- const timer = setTimeout(() => {
392
+ const treeKill = (force) => {
393
+ if (isWindows && child.pid !== void 0) {
388
394
  try {
389
- child.kill("SIGKILL");
395
+ spawn("taskkill", ["/pid", String(child.pid), "/t", ...force ? ["/f"] : []], { stdio: "ignore" });
390
396
  } catch {
391
397
  }
392
- resolve3();
398
+ } else {
399
+ try {
400
+ child.kill(force ? "SIGKILL" : "SIGTERM");
401
+ } catch {
402
+ }
403
+ }
404
+ };
405
+ const gracePeriodMs = 2e3;
406
+ await new Promise((resolve2) => {
407
+ const timer = setTimeout(() => {
408
+ treeKill(true);
409
+ resolve2();
393
410
  }, gracePeriodMs);
394
411
  child.once("exit", () => {
395
412
  clearTimeout(timer);
396
- resolve3();
413
+ resolve2();
397
414
  });
398
- try {
399
- child.kill(isWindows ? void 0 : "SIGTERM");
400
- } catch {
401
- }
415
+ treeKill(false);
402
416
  });
403
417
  rejectAllPending(new Error("stdio transport: closed"));
404
418
  },
@@ -662,7 +676,9 @@ function diffReports(baseline, current) {
662
676
  }
663
677
  function formatDiff(summary) {
664
678
  const lines = [];
665
- const arrow = summary.baselineGrade === summary.currentGrade ? "\u2192" : "\u2192";
679
+ let arrow = "\u2192";
680
+ if (summary.currentScore > summary.baselineScore) arrow = "\u2191";
681
+ else if (summary.currentScore < summary.baselineScore) arrow = "\u2193";
666
682
  lines.push(
667
683
  `Grade ${summary.baselineGrade} (${summary.baselineScore}%) ${arrow} ${summary.currentGrade} (${summary.currentScore}%)`
668
684
  );
@@ -699,17 +715,37 @@ function hasRegressions(summary) {
699
715
  }
700
716
 
701
717
  // src/mcp/server.ts
702
- import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "fs";
703
- import { basename, dirname, join as join2, resolve as resolve2 } from "path";
704
- import { fileURLToPath } from "url";
718
+ import { realpathSync } from "fs";
719
+ import { basename, dirname as dirname2 } from "path";
720
+ import { fileURLToPath as fileURLToPath2 } from "url";
705
721
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
706
722
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
707
723
 
724
+ // src/pkg-version.ts
725
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
726
+ import { dirname, join as join2 } from "path";
727
+ import { fileURLToPath } from "url";
728
+ function readPackageVersion(metaUrl) {
729
+ let dir = dirname(fileURLToPath(metaUrl));
730
+ for (; ; ) {
731
+ const pkgPath = join2(dir, "package.json");
732
+ if (existsSync2(pkgPath)) {
733
+ try {
734
+ return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "0.0.0";
735
+ } catch {
736
+ return "0.0.0";
737
+ }
738
+ }
739
+ const parent = dirname(dir);
740
+ if (parent === dir) return "0.0.0";
741
+ dir = parent;
742
+ }
743
+ }
744
+
708
745
  // src/mcp/tools.ts
709
746
  import { z } from "zod";
710
747
 
711
748
  // src/runner.ts
712
- import { createRequire } from "module";
713
749
  import { request as request2 } from "undici";
714
750
 
715
751
  // src/badge.ts
@@ -1604,8 +1640,7 @@ var TEST_DEFINITIONS = [
1604
1640
 
1605
1641
  // src/runner.ts
1606
1642
  var TEST_DEFINITIONS_MAP = new Map(TEST_DEFINITIONS.map((t) => [t.id, t]));
1607
- var _require = createRequire(import.meta.url);
1608
- var { version: TOOL_VERSION } = _require("../package.json");
1643
+ var TOOL_VERSION = readPackageVersion(import.meta.url);
1609
1644
  var SPEC_VERSION = "2025-11-25";
1610
1645
  var SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`;
1611
1646
  var VALID_CONTENT_TYPES = ["text", "image", "audio", "resource", "resource_link"];
@@ -1808,10 +1843,11 @@ async function runComplianceSuite(target, options = {}) {
1808
1843
  const retries = options.retries || 0;
1809
1844
  let sessionId = null;
1810
1845
  let negotiatedProtocolVersion = null;
1811
- async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs) {
1846
+ async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs, omitUserHeaders) {
1812
1847
  const res = await transport.request(method, params, idCounter, {
1813
1848
  timeout: timeoutMs,
1814
- headers: extraHeaders
1849
+ headers: extraHeaders,
1850
+ omitUserHeaders
1815
1851
  });
1816
1852
  return {
1817
1853
  statusCode: res.statusCode ?? 200,
@@ -2824,7 +2860,7 @@ async function runComplianceSuite(target, options = {}) {
2824
2860
  issues.push("Tool missing name");
2825
2861
  continue;
2826
2862
  }
2827
- if (tool.name.length > 128 || !/^[A-Za-z0-9_.\-]+$/.test(tool.name)) {
2863
+ if (tool.name.length > 128 || !/^[A-Za-z0-9_.-]+$/.test(tool.name)) {
2828
2864
  issues.push(`${tool.name}: name format invalid`);
2829
2865
  }
2830
2866
  if (!tool.description) warnings.push(`Tool "${tool.name}" missing description`);
@@ -3525,7 +3561,9 @@ async function runComplianceSuite(target, options = {}) {
3525
3561
  const noAuthHeaders = {};
3526
3562
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
3527
3563
  try {
3528
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
3564
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout, [
3565
+ "authorization"
3566
+ ]);
3529
3567
  if (res.statusCode === 401 || res.statusCode === 403) {
3530
3568
  return { passed: true, details: `HTTP ${res.statusCode} (unauthenticated request rejected)` };
3531
3569
  }
@@ -3548,7 +3586,9 @@ async function runComplianceSuite(target, options = {}) {
3548
3586
  const noAuthHeaders = {};
3549
3587
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
3550
3588
  try {
3551
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
3589
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout, [
3590
+ "authorization"
3591
+ ]);
3552
3592
  if (res.statusCode === 401) {
3553
3593
  const wwwAuth = res.headers["www-authenticate"];
3554
3594
  if (wwwAuth) {
@@ -3576,14 +3616,16 @@ async function runComplianceSuite(target, options = {}) {
3576
3616
  "basic/authorization",
3577
3617
  async () => {
3578
3618
  if (!hasAuth) {
3579
- return { passed: false, details: "Skipped: server does not require auth" };
3619
+ return { passed: true, details: "Skipped: server does not require auth" };
3580
3620
  }
3581
3621
  const malformedHeaders = {
3582
3622
  Authorization: "Bearer INVALID_GARBAGE_TOKEN_!@#$%^&*()"
3583
3623
  };
3584
3624
  if (sessionId) malformedHeaders["mcp-session-id"] = sessionId;
3585
3625
  try {
3586
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout);
3626
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout, [
3627
+ "authorization"
3628
+ ]);
3587
3629
  if (res.statusCode === 401 || res.statusCode === 403) {
3588
3630
  return { passed: true, details: `HTTP ${res.statusCode} (malformed auth rejected)` };
3589
3631
  }
@@ -3673,7 +3715,9 @@ async function runComplianceSuite(target, options = {}) {
3673
3715
  "mcp-session-id": sessionId
3674
3716
  };
3675
3717
  try {
3676
- const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout);
3718
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout, [
3719
+ "authorization"
3720
+ ]);
3677
3721
  if (res.statusCode === 401 || res.statusCode === 403) {
3678
3722
  return { passed: true, details: `HTTP ${res.statusCode} (session ID alone not sufficient for auth)` };
3679
3723
  }
@@ -4074,9 +4118,10 @@ async function runComplianceSuite(target, options = {}) {
4074
4118
  return { passed: true, details: "No tools available to test (skipped)" };
4075
4119
  }
4076
4120
  try {
4121
+ const maliciousArgs = JSON.parse('{"__injected_param__":"malicious_value","__proto__":{"admin":true}}');
4077
4122
  const res = await rpc("tools/call", {
4078
4123
  name: toolNames[0],
4079
- arguments: { __injected_param__: "malicious_value", __proto__: { admin: true } }
4124
+ arguments: maliciousArgs
4080
4125
  });
4081
4126
  const error = res.body?.error;
4082
4127
  if (error) {
@@ -4483,7 +4528,7 @@ function registerTools(server) {
4483
4528
  {
4484
4529
  url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
4485
4530
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
4486
- headers: z.record(z.string()).optional().describe('Additional headers to include on all requests (e.g., {"X-Api-Key": "abc"})'),
4531
+ headers: z.record(z.string(), z.string()).optional().describe('Additional headers to include on all requests (e.g., {"X-Api-Key": "abc"})'),
4487
4532
  timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)"),
4488
4533
  retries: z.number().int().min(0).max(10).optional().describe("Number of retries for failed tests (default: 0, max: 10)"),
4489
4534
  only: z.array(z.string()).optional().describe("Only run tests matching these categories or test IDs"),
@@ -4549,7 +4594,7 @@ ${JSON.stringify(report, null, 2)}` }
4549
4594
  {
4550
4595
  url: z.string().url().describe("The MCP server URL to test"),
4551
4596
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
4552
- headers: z.record(z.string()).optional().describe("Additional headers to include on all requests"),
4597
+ headers: z.record(z.string(), z.string()).optional().describe("Additional headers to include on all requests"),
4553
4598
  timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)")
4554
4599
  },
4555
4600
  {
@@ -4645,25 +4690,7 @@ ${TEST_DEFINITIONS.map((t) => t.id).join(", ")}`
4645
4690
  }
4646
4691
 
4647
4692
  // src/mcp/server.ts
4648
- function findPackageVersion() {
4649
- let dir = dirname(fileURLToPath(import.meta.url));
4650
- const root = resolve2(dir, "..", "..", "..");
4651
- while (dir !== root) {
4652
- const pkgPath = join2(dir, "package.json");
4653
- if (existsSync2(pkgPath)) {
4654
- try {
4655
- return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "0.0.0";
4656
- } catch {
4657
- return "0.0.0";
4658
- }
4659
- }
4660
- const parent = dirname(dir);
4661
- if (parent === dir) break;
4662
- dir = parent;
4663
- }
4664
- return "0.0.0";
4665
- }
4666
- var version = findPackageVersion();
4693
+ var version = readPackageVersion(import.meta.url);
4667
4694
  function createComplianceServer() {
4668
4695
  const server = new McpServer({ name: "mcp-compliance", version });
4669
4696
  registerTools(server);
@@ -4678,10 +4705,10 @@ function isInvokedDirectly() {
4678
4705
  const argv1 = process.argv[1];
4679
4706
  if (!argv1) return false;
4680
4707
  try {
4681
- const selfPath = realpathSync(fileURLToPath(import.meta.url));
4708
+ const selfPath = realpathSync(fileURLToPath2(import.meta.url));
4682
4709
  if (realpathSync(argv1) !== selfPath) return false;
4683
4710
  const file = basename(selfPath);
4684
- const parent = basename(dirname(selfPath));
4711
+ const parent = basename(dirname2(selfPath));
4685
4712
  return parent === "mcp" && (file === "server.js" || file === "server.ts");
4686
4713
  } catch {
4687
4714
  return false;
@@ -4970,8 +4997,7 @@ function formatSarif(report) {
4970
4997
  {
4971
4998
  physicalLocation: {
4972
4999
  artifactLocation: {
4973
- uri: report.url,
4974
- uriBaseId: "MCP_SERVER"
5000
+ uri: report.url
4975
5001
  }
4976
5002
  }
4977
5003
  }
@@ -5235,14 +5261,13 @@ function splitStdioTarget(s) {
5235
5261
  }
5236
5262
 
5237
5263
  // src/token-store.ts
5238
- import { createHash as createHash2 } from "crypto";
5239
5264
  import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
5240
5265
  import { homedir } from "os";
5241
- import { dirname as dirname2, join as join3 } from "path";
5266
+ import { dirname as dirname3, join as join3 } from "path";
5242
5267
  var STORE_DIR = join3(homedir(), ".mcp-compliance");
5243
5268
  var STORE_PATH = join3(STORE_DIR, "tokens.json");
5244
5269
  function hashUrl(url) {
5245
- return createHash2("sha256").update(url).digest("hex").slice(0, 24);
5270
+ return urlHash(url);
5246
5271
  }
5247
5272
  function readStore() {
5248
5273
  if (!existsSync3(STORE_PATH)) return {};
@@ -5255,7 +5280,7 @@ function readStore() {
5255
5280
  }
5256
5281
  }
5257
5282
  function writeStore(store) {
5258
- const dir = dirname2(STORE_PATH);
5283
+ const dir = dirname3(STORE_PATH);
5259
5284
  try {
5260
5285
  if (!existsSync3(dir)) mkdirSync(dir, { recursive: true, mode: 448 });
5261
5286
  writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), { mode: 384 });
@@ -5292,7 +5317,7 @@ function deleteToken(hash) {
5292
5317
  }
5293
5318
 
5294
5319
  // src/index.ts
5295
- var require2 = createRequire2(import.meta.url);
5320
+ var require2 = createRequire(import.meta.url);
5296
5321
  var { version: version2 } = require2("../package.json");
5297
5322
  function parseHeaderArg(value, prev) {
5298
5323
  const idx = value.indexOf(":");
@@ -5432,8 +5457,9 @@ function isPrivateHost(urlStr) {
5432
5457
  if (a === 192 && b === 168) return true;
5433
5458
  if (a === 0) return true;
5434
5459
  }
5435
- if (host === "::1" || host === "[::1]") return true;
5436
- if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
5460
+ const v6 = host.replace(/^\[|\]$/g, "");
5461
+ if (v6 === "::1") return true;
5462
+ if (v6.includes(":") && (v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd"))) return true;
5437
5463
  return false;
5438
5464
  }
5439
5465
  async function promptYesNo(message) {
@@ -5452,7 +5478,14 @@ program.command("test").description("Run the full compliance test suite against
5452
5478
  "[target]",
5453
5479
  "Server URL, or command to spawn as a stdio server (optional when a config file defines 'target')"
5454
5480
  ).argument("[extraArgs...]", "Additional args passed to the stdio command").addOption(
5455
- new Option("--format <format>", "Output format").choices(["terminal", "json", "sarif", "github", "markdown", "html"]).default("terminal")
5481
+ new Option("--format <format>", "Output format (default: terminal, or `format` in config)").choices([
5482
+ "terminal",
5483
+ "json",
5484
+ "sarif",
5485
+ "github",
5486
+ "markdown",
5487
+ "html"
5488
+ ])
5456
5489
  ).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(
5457
5490
  new Option(
5458
5491
  "--transport <kind>",
@@ -5473,8 +5506,7 @@ program.command("test").description("Run the full compliance test suite against
5473
5506
  {}
5474
5507
  ).option("--auth <token>", 'Shorthand for -H "Authorization: <token>" (HTTP only)').option("-E, --env <var>", 'Set env var for stdio command ("KEY=VALUE", repeatable)', parseEnvVar, {}).option("--env-file <path>", "Load env vars from file (KEY=VALUE per line, stdio only)").option("--cwd <dir>", "Working directory for stdio command").option(
5475
5508
  "--timeout <ms>",
5476
- "Per-request timeout in milliseconds (applies to every test request after the initial handshake)",
5477
- "15000"
5509
+ "Per-request timeout in ms after the initial handshake (default: 15000, or `timeout` in config)"
5478
5510
  ).option(
5479
5511
  "--startup-timeout <ms>",
5480
5512
  "Deadline for the initial initialize handshake (default: max(--timeout, 60000); covers cold `npx` cache fetches before a stdio server starts)"
@@ -5482,7 +5514,7 @@ program.command("test").description("Run the full compliance test suite against
5482
5514
  "--concurrency <n>",
5483
5515
  "Max parallel-safe tests in flight (default 1; see docs/PERFORMANCE.md before raising)",
5484
5516
  "1"
5485
- ).option("--preflight-timeout <ms>", "Preflight connectivity check timeout in milliseconds").option("--retries <n>", "Number of retries for failed tests", "0").option(
5517
+ ).option("--preflight-timeout <ms>", "Preflight connectivity check timeout in milliseconds").option("--retries <n>", "Number of retries for failed tests (default: 0, or `retries` in config)").option(
5486
5518
  "--only <items>",
5487
5519
  'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
5488
5520
  parseList
@@ -5531,6 +5563,11 @@ ${defs.length} tests would run for transport=${transportKind}`));
5531
5563
  const skip = opts.skip ?? config?.skip;
5532
5564
  const verbose = opts.verbose ?? config?.verbose;
5533
5565
  const strict = opts.strict ?? config?.strict;
5566
+ opts.format = opts.format ?? config?.format ?? "terminal";
5567
+ let timeout = config?.timeout ?? 15e3;
5568
+ if (opts.timeout !== void 0) timeout = parsePositiveInt(opts.timeout, "--timeout", 1);
5569
+ let retries = config?.retries ?? 0;
5570
+ if (opts.retries !== void 0) retries = parsePositiveInt(opts.retries, "--retries");
5534
5571
  async function runOnce() {
5535
5572
  if (opts.format === "terminal") {
5536
5573
  console.log(chalk2.dim(`
@@ -5538,10 +5575,10 @@ Testing ${describeTarget(transportTarget)}...
5538
5575
  `));
5539
5576
  }
5540
5577
  const report2 = await runComplianceSuite(transportTarget, {
5541
- timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
5578
+ timeout,
5542
5579
  startupTimeout: opts.startupTimeout ? parsePositiveInt(opts.startupTimeout, "--startup-timeout", 1) : config?.startupTimeout,
5543
5580
  preflightTimeout: opts.preflightTimeout ? parsePositiveInt(opts.preflightTimeout, "--preflight-timeout", 1) : config?.preflightTimeout,
5544
- retries: parsePositiveInt(opts.retries, "--retries"),
5581
+ retries,
5545
5582
  concurrency: parsePositiveInt(opts.concurrency, "--concurrency", 1),
5546
5583
  only,
5547
5584
  skip,
@@ -5664,7 +5701,7 @@ program.command("badge").description("Run tests and publish a shareable complian
5664
5701
  'Add header to all requests (format: "Key: Value", repeatable; HTTP only)',
5665
5702
  parseHeaderArg,
5666
5703
  {}
5667
- ).option("--auth <token>", 'Shorthand for -H "Authorization: <token>" (HTTP only)').option("-E, --env <var>", 'Set env var for stdio command ("KEY=VALUE", repeatable)', parseEnvVar, {}).option("--env-file <path>", "Load env vars from file (stdio only)").option("--cwd <dir>", "Working directory for stdio command").option("--timeout <ms>", "Request timeout in milliseconds", "15000").option("--no-publish", "Do not publish the report to mcp.hosting").option("--output <file>", "Write a local SVG badge to the given path (works for any transport)").option("--no-color", "Disable colored output (also honors NO_COLOR env var)").action(
5704
+ ).option("--auth <token>", 'Shorthand for -H "Authorization: <token>" (HTTP only)').option("-E, --env <var>", 'Set env var for stdio command ("KEY=VALUE", repeatable)', parseEnvVar, {}).option("--env-file <path>", "Load env vars from file (stdio only)").option("--cwd <dir>", "Working directory for stdio command").option("--timeout <ms>", "Request timeout in milliseconds (default: 15000, or `timeout` in config)").option("--no-publish", "Do not publish the report to mcp.hosting").option("--output <file>", "Write a local SVG badge to the given path (works for any transport)").option("--no-color", "Disable colored output (also honors NO_COLOR env var)").action(
5668
5705
  async (target, extraArgs, opts) => {
5669
5706
  if (opts.color === false) chalk2.level = 0;
5670
5707
  try {
@@ -5698,8 +5735,10 @@ Warning: ${transportTarget.url} looks like a private/internal address. Publishin
5698
5735
  console.log(chalk2.dim(`
5699
5736
  Testing ${describeTarget(transportTarget)}...
5700
5737
  `));
5738
+ let badgeTimeout = config?.timeout ?? 15e3;
5739
+ if (opts.timeout !== void 0) badgeTimeout = parsePositiveInt(opts.timeout, "--timeout", 1);
5701
5740
  const report = await runComplianceSuite(transportTarget, {
5702
- timeout: parsePositiveInt(opts.timeout, "--timeout", 1)
5741
+ timeout: badgeTimeout
5703
5742
  });
5704
5743
  let markdown = report.badge.markdown;
5705
5744
  if (shouldPublish && transportTarget.type === "http") {
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  SPEC_BASE,
3
3
  TEST_DEFINITIONS,
4
+ readPackageVersion,
4
5
  runComplianceSuite
5
- } from "../chunk-6PF56RRO.js";
6
+ } from "../chunk-A3UG3J63.js";
6
7
 
7
8
  // src/mcp/server.ts
8
- import { existsSync, readFileSync, realpathSync } from "fs";
9
- import { basename, dirname, join, resolve } from "path";
9
+ import { realpathSync } from "fs";
10
+ import { basename, dirname } from "path";
10
11
  import { fileURLToPath } from "url";
11
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -20,7 +21,7 @@ function registerTools(server) {
20
21
  {
21
22
  url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
22
23
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
23
- headers: z.record(z.string()).optional().describe('Additional headers to include on all requests (e.g., {"X-Api-Key": "abc"})'),
24
+ headers: z.record(z.string(), z.string()).optional().describe('Additional headers to include on all requests (e.g., {"X-Api-Key": "abc"})'),
24
25
  timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)"),
25
26
  retries: z.number().int().min(0).max(10).optional().describe("Number of retries for failed tests (default: 0, max: 10)"),
26
27
  only: z.array(z.string()).optional().describe("Only run tests matching these categories or test IDs"),
@@ -86,7 +87,7 @@ ${JSON.stringify(report, null, 2)}` }
86
87
  {
87
88
  url: z.string().url().describe("The MCP server URL to test"),
88
89
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
89
- headers: z.record(z.string()).optional().describe("Additional headers to include on all requests"),
90
+ headers: z.record(z.string(), z.string()).optional().describe("Additional headers to include on all requests"),
90
91
  timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)")
91
92
  },
92
93
  {
@@ -182,25 +183,7 @@ ${TEST_DEFINITIONS.map((t) => t.id).join(", ")}`
182
183
  }
183
184
 
184
185
  // src/mcp/server.ts
185
- function findPackageVersion() {
186
- let dir = dirname(fileURLToPath(import.meta.url));
187
- const root = resolve(dir, "..", "..", "..");
188
- while (dir !== root) {
189
- const pkgPath = join(dir, "package.json");
190
- if (existsSync(pkgPath)) {
191
- try {
192
- return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
193
- } catch {
194
- return "0.0.0";
195
- }
196
- }
197
- const parent = dirname(dir);
198
- if (parent === dir) break;
199
- dir = parent;
200
- }
201
- return "0.0.0";
202
- }
203
- var version = findPackageVersion();
186
+ var version = readPackageVersion(import.meta.url);
204
187
  function createComplianceServer() {
205
188
  const server = new McpServer({ name: "mcp-compliance", version });
206
189
  registerTools(server);
package/dist/runner.d.ts CHANGED
@@ -92,24 +92,6 @@ type TransportTarget = {
92
92
  /** All 88 test IDs with descriptions for the explain command */
93
93
  declare const TEST_DEFINITIONS: TestDefinition[];
94
94
 
95
- declare function computeGrade(score: number): Grade;
96
- declare function computeScore(tests: TestResult[]): {
97
- score: number;
98
- grade: Grade;
99
- overall: "pass" | "partial" | "fail";
100
- summary: {
101
- total: number;
102
- passed: number;
103
- failed: number;
104
- required: number;
105
- requiredPassed: number;
106
- };
107
- categories: Record<string, {
108
- passed: number;
109
- total: number;
110
- }>;
111
- };
112
-
113
95
  /**
114
96
  * Generate a short, deterministic hash of a URL for badge paths.
115
97
  * SHA-256 truncated to 24 hex chars (96 bits of entropy) — matches the
@@ -131,6 +113,24 @@ declare function generateBadge(url: string): {
131
113
  html: string;
132
114
  };
133
115
 
116
+ declare function computeGrade(score: number): Grade;
117
+ declare function computeScore(tests: TestResult[]): {
118
+ score: number;
119
+ grade: Grade;
120
+ overall: "pass" | "partial" | "fail";
121
+ summary: {
122
+ total: number;
123
+ passed: number;
124
+ failed: number;
125
+ required: number;
126
+ requiredPassed: number;
127
+ };
128
+ categories: Record<string, {
129
+ passed: number;
130
+ total: number;
131
+ }>;
132
+ };
133
+
134
134
  /**
135
135
  * Parse a Server-Sent Events response body and extract the first
136
136
  * JSON-RPC response message. Returns null if none found.
package/dist/runner.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  previewTests,
11
11
  runComplianceSuite,
12
12
  urlHash
13
- } from "./chunk-6PF56RRO.js";
13
+ } from "./chunk-A3UG3J63.js";
14
14
  export {
15
15
  SPEC_BASE,
16
16
  SPEC_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/mcp-compliance",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "mcpName": "io.github.YawLabs/mcp-compliance",
5
5
  "description": "CLI tool and MCP server that tests MCP servers for spec compliance",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "main": "./dist/runner.js",
21
21
  "types": "./dist/runner.d.ts",
22
22
  "bin": {
23
- "mcp-compliance": "./dist/index.js"
23
+ "mcp-compliance": "dist/index.js"
24
24
  },
25
25
  "files": [
26
26
  "dist",
@@ -45,17 +45,17 @@
45
45
  "chalk": "^5.4.1",
46
46
  "commander": "^14.0.3",
47
47
  "undici": "^7.8.0",
48
- "zod": "^3.24.4"
48
+ "zod": "^4.4.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@biomejs/biome": "^1.9.4",
51
+ "@biomejs/biome": "^2.4.16",
52
52
  "@types/node": "^25.5.2",
53
53
  "ajv": "^8.18.0",
54
54
  "ajv-formats": "^3.0.1",
55
55
  "tsup": "^8.4.0",
56
56
  "tsx": "^4.21.0",
57
- "typescript": "^5.8.3",
58
- "vitest": "^3.1.1"
57
+ "typescript": "^6.0.3",
58
+ "vitest": "^4.1.8"
59
59
  },
60
60
  "engines": {
61
61
  "node": ">=20"