@yawlabs/mcp-compliance 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -18,16 +18,14 @@ import { createRequire } from "module";
18
18
  import { request } from "undici";
19
19
 
20
20
  // src/badge.ts
21
+ import { createHash } from "crypto";
22
+ function urlHash(url) {
23
+ return createHash("sha256").update(url).digest("hex").slice(0, 12);
24
+ }
21
25
  function generateBadge(url) {
22
- let parsed;
23
- try {
24
- parsed = new URL(url);
25
- } catch {
26
- parsed = new URL("https://unknown");
27
- }
28
- const encoded = encodeURIComponent(parsed.href);
29
- const imageUrl = `https://mcp.hosting/api/compliance/${encoded}/badge`;
30
- const reportUrl = `https://mcp.hosting/compliance/${encoded}`;
26
+ const hash = urlHash(url);
27
+ const imageUrl = `https://mcp.hosting/api/compliance/${hash}/badge`;
28
+ const reportUrl = `https://mcp.hosting/compliance/${hash}`;
31
29
  return {
32
30
  imageUrl,
33
31
  reportUrl,
@@ -73,7 +71,7 @@ function computeScore(tests) {
73
71
 
74
72
  // src/types.ts
75
73
  var TEST_DEFINITIONS = [
76
- // ── Transport (7 tests) ──────────────────────────────────────────
74
+ // ── Transport (10 tests) ─────────────────────────────────────────
77
75
  {
78
76
  id: "transport-post",
79
77
  name: "HTTP POST accepted",
@@ -137,7 +135,34 @@ var TEST_DEFINITIONS = [
137
135
  description: "Sends a JSON-RPC batch request (array of messages) and verifies the server rejects it with an error. MCP does not support JSON-RPC batch requests.",
138
136
  recommendation: "Check if the parsed JSON body is an array. If so, return a JSON-RPC error or HTTP 400. Do not process batch requests \u2014 MCP explicitly forbids them."
139
137
  },
140
- // ── Lifecycle (10 tests) ─────────────────────────────────────────
138
+ {
139
+ id: "transport-content-type-init",
140
+ name: "Initialize response has valid content type",
141
+ category: "transport",
142
+ required: false,
143
+ specRef: "basic/transports#streamable-http",
144
+ description: "Validates that the initialize response uses application/json or text/event-stream content type. Some servers return other types for the handshake.",
145
+ recommendation: 'Ensure the initialize response uses Content-Type "application/json" or "text/event-stream". Do not return text/html or other types for JSON-RPC responses.'
146
+ },
147
+ {
148
+ id: "transport-get-stream",
149
+ name: "GET with session returns SSE or 405",
150
+ category: "transport",
151
+ required: false,
152
+ specRef: "basic/transports#streamable-http",
153
+ description: "Tests the GET endpoint with an active session ID for server-initiated messages. After initialization, the server should either return an SSE stream or 405.",
154
+ recommendation: "If your server supports server-initiated messages, return text/event-stream on GET with a valid session ID. Otherwise, return 405 Method Not Allowed."
155
+ },
156
+ {
157
+ id: "transport-concurrent",
158
+ name: "Handles concurrent requests",
159
+ category: "transport",
160
+ required: false,
161
+ specRef: "basic/transports#streamable-http",
162
+ description: "Sends multiple JSON-RPC requests in parallel and verifies the server responds to all with correct matching IDs. Tests that the server can handle concurrent connections.",
163
+ recommendation: "Ensure your server can handle multiple simultaneous requests. Each response must include the correct id matching the request. Use async handlers or connection pooling."
164
+ },
165
+ // ── Lifecycle (12 tests) ─────────────────────────────────────────
141
166
  {
142
167
  id: "lifecycle-init",
143
168
  name: "Initialize handshake",
@@ -228,6 +253,24 @@ var TEST_DEFINITIONS = [
228
253
  description: "If the server declares completions capability, tests that the completion/complete method is accepted.",
229
254
  recommendation: 'If you declare completions in capabilities, implement the "completion/complete" handler. Return a completion object with a values array, even if empty.'
230
255
  },
256
+ {
257
+ id: "lifecycle-cancellation",
258
+ name: "Handles cancellation notifications",
259
+ category: "lifecycle",
260
+ required: false,
261
+ specRef: "basic/utilities#cancellation",
262
+ description: "Tests that the server accepts notifications/cancelled without error. Servers should gracefully handle cancellation of unknown or completed requests.",
263
+ recommendation: "Accept notifications/cancelled and stop any in-progress work for the referenced requestId. If the request is unknown or already complete, silently ignore the cancellation."
264
+ },
265
+ {
266
+ id: "lifecycle-progress",
267
+ name: "Accepts progress notifications",
268
+ category: "lifecycle",
269
+ required: false,
270
+ specRef: "basic/utilities#progress",
271
+ description: "Tests that the server accepts notifications/progress without error. Servers should handle progress notifications for request tracking.",
272
+ recommendation: "Accept notifications/progress with progressToken, progress, and optional total fields. Ignore notifications for unknown progress tokens."
273
+ },
231
274
  // ── Tools (4 tests) ──────────────────────────────────────────────
232
275
  {
233
276
  id: "tools-list",
@@ -569,19 +612,39 @@ async function mcpNotification(backendUrl, method, params, extraHeaders, timeout
569
612
  return { statusCode: res.statusCode, headers: responseHeaders };
570
613
  }
571
614
  async function runComplianceSuite(url, options = {}) {
572
- let parsed;
573
615
  try {
574
- parsed = new URL(url);
616
+ const parsed = new URL(url);
575
617
  if (!["http:", "https:"].includes(parsed.protocol)) {
576
618
  throw new Error("Only HTTP and HTTPS URLs are supported");
577
619
  }
578
620
  } catch (e) {
579
- if (e.message.includes("Only HTTP")) throw e;
621
+ if (e instanceof Error && e.message.includes("Only HTTP")) throw e;
580
622
  throw new Error(`Invalid URL: ${url}`);
581
623
  }
582
624
  const backendUrl = url;
625
+ let serverReachable = true;
626
+ try {
627
+ const preflight = await request(backendUrl, {
628
+ method: "POST",
629
+ headers: {
630
+ "Content-Type": "application/json",
631
+ Accept: "application/json, text/event-stream",
632
+ ...options.headers
633
+ },
634
+ body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
635
+ signal: AbortSignal.timeout(Math.min(options.timeout || 15e3, 1e4))
636
+ });
637
+ await preflight.body.text();
638
+ } catch {
639
+ serverReachable = false;
640
+ }
583
641
  const tests = [];
584
642
  const warnings = [];
643
+ if (!serverReachable) {
644
+ warnings.push(
645
+ `Server at ${url} is unreachable \u2014 all tests will fail. Check the URL and ensure the server is running.`
646
+ );
647
+ }
585
648
  const nextId = createIdCounter(1e3);
586
649
  const timeout = options.timeout || 15e3;
587
650
  const retries = options.retries || 0;
@@ -626,7 +689,8 @@ async function runComplianceSuite(url, options = {}) {
626
689
  if (lastResult.passed) break;
627
690
  if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
628
691
  } catch (err) {
629
- lastResult = { passed: false, details: `Error: ${err.message}` };
692
+ const message = err instanceof Error ? err.message : String(err);
693
+ lastResult = { passed: false, details: `Error: ${message}` };
630
694
  if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
631
695
  }
632
696
  }
@@ -655,10 +719,23 @@ async function runComplianceSuite(url, options = {}) {
655
719
  body: JSON.stringify({ jsonrpc: "2.0", id: 99901, method: "ping" }),
656
720
  signal: AbortSignal.timeout(timeout)
657
721
  });
658
- await res.body.text();
659
- const passed = res.statusCode >= 200 && res.statusCode < 300;
660
- const note = res.statusCode === 401 || res.statusCode === 403 ? " (auth required)" : "";
661
- return { passed, details: `HTTP ${res.statusCode}${note}` };
722
+ const text = await res.body.text();
723
+ if (res.statusCode >= 200 && res.statusCode < 300) {
724
+ return { passed: true, details: `HTTP ${res.statusCode}` };
725
+ }
726
+ if (res.statusCode === 401 || res.statusCode === 403) {
727
+ return { passed: false, details: `HTTP ${res.statusCode} (auth required \u2014 pass --auth)` };
728
+ }
729
+ if (res.statusCode === 400) {
730
+ try {
731
+ const body = JSON.parse(text);
732
+ if (body?.error || body?.jsonrpc) {
733
+ return { passed: true, details: "HTTP 400 with JSON-RPC response (server requires initialization first)" };
734
+ }
735
+ } catch {
736
+ }
737
+ }
738
+ return { passed: false, details: `HTTP ${res.statusCode}` };
662
739
  }
663
740
  );
664
741
  await test(
@@ -688,18 +765,29 @@ async function runComplianceSuite(url, options = {}) {
688
765
  false,
689
766
  "basic/transports#streamable-http",
690
767
  async () => {
768
+ const getHeaders = { Accept: "text/event-stream", ...buildHeaders() };
691
769
  const res = await request(backendUrl, {
692
770
  method: "GET",
693
- headers: { Accept: "text/event-stream", ...userHeaders },
771
+ headers: getHeaders,
694
772
  signal: AbortSignal.timeout(timeout)
695
773
  });
696
- await res.body.text();
774
+ const body = await res.body.text();
697
775
  const ct = (res.headers["content-type"] || "").toLowerCase();
698
776
  if (res.statusCode === 405) {
699
777
  return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
700
778
  }
701
779
  if (ct.includes("text/event-stream")) {
702
- return { passed: true, details: "Returns text/event-stream for SSE" };
780
+ if (body.trim().length > 0) {
781
+ const hasDataFields = body.includes("data:");
782
+ const hasEventFields = body.includes("event:");
783
+ if (!hasDataFields && !hasEventFields) {
784
+ return {
785
+ passed: false,
786
+ details: "Content-Type is text/event-stream but body has no SSE data: or event: fields"
787
+ };
788
+ }
789
+ }
790
+ return { passed: true, details: "Returns text/event-stream with valid SSE format" };
703
791
  }
704
792
  if (res.statusCode >= 200 && res.statusCode < 300) {
705
793
  return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
@@ -707,31 +795,6 @@ async function runComplianceSuite(url, options = {}) {
707
795
  return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
708
796
  }
709
797
  );
710
- await test(
711
- "transport-delete",
712
- "DELETE accepted or returns 405",
713
- "transport",
714
- false,
715
- "basic/transports#streamable-http",
716
- async () => {
717
- const res = await request(backendUrl, {
718
- method: "DELETE",
719
- headers: { ...userHeaders },
720
- signal: AbortSignal.timeout(timeout)
721
- });
722
- await res.body.text();
723
- if (res.statusCode === 405) {
724
- return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
725
- }
726
- if (res.statusCode >= 200 && res.statusCode < 300) {
727
- return { passed: true, details: `HTTP ${res.statusCode} (session termination supported)` };
728
- }
729
- if (res.statusCode === 400 || res.statusCode === 404) {
730
- return { passed: true, details: `HTTP ${res.statusCode} (no active session, acceptable)` };
731
- }
732
- return { passed: false, details: `HTTP ${res.statusCode}` };
733
- }
734
- );
735
798
  await test(
736
799
  "transport-batch-reject",
737
800
  "Rejects JSON-RPC batch requests",
@@ -769,7 +832,7 @@ async function runComplianceSuite(url, options = {}) {
769
832
  try {
770
833
  initRes = await rpc("initialize", {
771
834
  protocolVersion: SPEC_VERSION,
772
- capabilities: { roots: { listChanged: true }, sampling: {} },
835
+ capabilities: {},
773
836
  clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
774
837
  });
775
838
  const result = initRes?.body?.result;
@@ -782,7 +845,7 @@ async function runComplianceSuite(url, options = {}) {
782
845
  if (sid) sessionId = sid;
783
846
  if (result.protocolVersion) negotiatedProtocolVersion = result.protocolVersion;
784
847
  }
785
- } catch (err) {
848
+ } catch {
786
849
  }
787
850
  try {
788
851
  await mcpNotification(backendUrl, "notifications/initialized", void 0, buildHeaders(), timeout);
@@ -901,7 +964,17 @@ async function runComplianceSuite(url, options = {}) {
901
964
  if (res.body?.error) {
902
965
  return { passed: false, details: `Error: ${res.body.error.code} \u2014 ${res.body.error.message}` };
903
966
  }
904
- return { passed: true, details: "logging/setLevel accepted" };
967
+ const invalidRes = await rpc("logging/setLevel", { level: "__invalid_level__" });
968
+ const validatesInput = !!invalidRes.body?.error;
969
+ const validLevels = ["debug", "warning", "error"];
970
+ const accepted = [];
971
+ for (const level of validLevels) {
972
+ const r = await rpc("logging/setLevel", { level });
973
+ if (!r.body?.error) accepted.push(level);
974
+ }
975
+ const details = validatesInput ? `logging/setLevel accepted (validates levels, ${accepted.length + 1} levels accepted)` : "logging/setLevel accepted (warning: server does not reject invalid log levels)";
976
+ if (!validatesInput) warnings.push("Server accepts invalid log levels without error");
977
+ return { passed: true, details };
905
978
  }
906
979
  );
907
980
  const hasCompletions = !!serverInfo.capabilities.completions;
@@ -930,6 +1003,59 @@ async function runComplianceSuite(url, options = {}) {
930
1003
  return { passed: true, details: "completion/complete accepted" };
931
1004
  }
932
1005
  );
1006
+ await test(
1007
+ "lifecycle-cancellation",
1008
+ "Handles cancellation notifications",
1009
+ "lifecycle",
1010
+ false,
1011
+ "basic/utilities#cancellation",
1012
+ async () => {
1013
+ const res = await mcpNotification(
1014
+ backendUrl,
1015
+ "notifications/cancelled",
1016
+ { requestId: 99999, reason: "compliance test" },
1017
+ buildHeaders(),
1018
+ timeout
1019
+ );
1020
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1021
+ return { passed: true, details: `HTTP ${res.statusCode} (cancellation accepted)` };
1022
+ }
1023
+ return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept cancellation notifications` };
1024
+ }
1025
+ );
1026
+ await test(
1027
+ "lifecycle-progress",
1028
+ "Accepts progress notifications",
1029
+ "lifecycle",
1030
+ false,
1031
+ "basic/utilities#progress",
1032
+ async () => {
1033
+ const res = await mcpNotification(
1034
+ backendUrl,
1035
+ "notifications/progress",
1036
+ { progressToken: "compliance-test-token", progress: 50, total: 100 },
1037
+ buildHeaders(),
1038
+ timeout
1039
+ );
1040
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1041
+ return { passed: true, details: `HTTP ${res.statusCode} (progress notification accepted)` };
1042
+ }
1043
+ return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept progress notifications` };
1044
+ }
1045
+ );
1046
+ await test(
1047
+ "transport-content-type-init",
1048
+ "Initialize response has valid content type",
1049
+ "transport",
1050
+ false,
1051
+ "basic/transports#streamable-http",
1052
+ async () => {
1053
+ if (!initRes) return { passed: false, details: "No init response to check" };
1054
+ const ct = (initRes.headers["content-type"] || "").toLowerCase();
1055
+ const valid = ct.includes("application/json") || ct.includes("text/event-stream");
1056
+ return { passed: valid, details: `Content-Type: ${ct || "missing"}` };
1057
+ }
1058
+ );
933
1059
  await test(
934
1060
  "transport-notification-202",
935
1061
  "Notification returns 202 Accepted",
@@ -987,6 +1113,88 @@ async function runComplianceSuite(url, options = {}) {
987
1113
  return { passed: false, details: `HTTP ${res.statusCode}` };
988
1114
  }
989
1115
  );
1116
+ await test(
1117
+ "transport-get-stream",
1118
+ "GET with session returns SSE or 405",
1119
+ "transport",
1120
+ false,
1121
+ "basic/transports#streamable-http",
1122
+ async () => {
1123
+ if (!sessionId) {
1124
+ return { passed: true, details: "No session ID \u2014 server-initiated messages not applicable" };
1125
+ }
1126
+ const res = await request(backendUrl, {
1127
+ method: "GET",
1128
+ headers: { Accept: "text/event-stream", ...buildHeaders() },
1129
+ signal: AbortSignal.timeout(Math.min(timeout, 3e3))
1130
+ });
1131
+ const body = await res.body.text();
1132
+ const ct = (res.headers["content-type"] || "").toLowerCase();
1133
+ if (res.statusCode === 405) {
1134
+ return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
1135
+ }
1136
+ if (ct.includes("text/event-stream")) {
1137
+ if (body.trim().length > 0) {
1138
+ const hasSSEFields = body.includes("data:") || body.includes("event:");
1139
+ if (!hasSSEFields) {
1140
+ return { passed: false, details: "Content-Type is text/event-stream but body has no SSE fields" };
1141
+ }
1142
+ }
1143
+ return { passed: true, details: "GET with session returns SSE stream for server-initiated messages" };
1144
+ }
1145
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1146
+ return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
1147
+ }
1148
+ return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
1149
+ }
1150
+ );
1151
+ await test(
1152
+ "transport-concurrent",
1153
+ "Handles concurrent requests",
1154
+ "transport",
1155
+ false,
1156
+ "basic/transports#streamable-http",
1157
+ async () => {
1158
+ const ids = [createIdCounter(99930)(), createIdCounter(99931)(), createIdCounter(99932)()];
1159
+ const promises = ids.map(
1160
+ (id) => request(backendUrl, {
1161
+ method: "POST",
1162
+ headers: {
1163
+ "Content-Type": "application/json",
1164
+ Accept: "application/json, text/event-stream",
1165
+ ...buildHeaders()
1166
+ },
1167
+ body: JSON.stringify({ jsonrpc: "2.0", id, method: "ping" }),
1168
+ signal: AbortSignal.timeout(timeout)
1169
+ }).then(async (res) => {
1170
+ const text = await res.body.text();
1171
+ const ct = (res.headers["content-type"] || "").toLowerCase();
1172
+ let body;
1173
+ if (ct.includes("text/event-stream")) {
1174
+ body = parseSSEResponse(text);
1175
+ }
1176
+ if (!body) {
1177
+ try {
1178
+ body = JSON.parse(text);
1179
+ } catch {
1180
+ }
1181
+ }
1182
+ return { statusCode: res.statusCode, body, requestId: id };
1183
+ })
1184
+ );
1185
+ const results = await Promise.all(promises);
1186
+ const issues = [];
1187
+ for (const r of results) {
1188
+ if (r.statusCode < 200 || r.statusCode >= 300) {
1189
+ issues.push(`Request id=${r.requestId}: HTTP ${r.statusCode}`);
1190
+ } else if (r.body?.id !== r.requestId) {
1191
+ issues.push(`Request id=${r.requestId}: response id=${r.body?.id} (mismatch)`);
1192
+ }
1193
+ }
1194
+ if (issues.length > 0) return { passed: false, details: issues.join("; ") };
1195
+ return { passed: true, details: `${results.length} concurrent requests handled correctly` };
1196
+ }
1197
+ );
990
1198
  const hasTools = !!serverInfo.capabilities.tools;
991
1199
  let cachedToolsList = null;
992
1200
  await test(
@@ -1008,6 +1216,7 @@ async function runComplianceSuite(url, options = {}) {
1008
1216
  };
1009
1217
  }
1010
1218
  );
1219
+ const toolsListOk = cachedToolsList !== null;
1011
1220
  await test(
1012
1221
  "tools-schema",
1013
1222
  "All tools have name and inputSchema",
@@ -1015,7 +1224,8 @@ async function runComplianceSuite(url, options = {}) {
1015
1224
  hasTools,
1016
1225
  "server/tools#data-types",
1017
1226
  async () => {
1018
- const tools = cachedToolsList ?? (await rpc("tools/list")).body?.result?.tools ?? [];
1227
+ if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
1228
+ const tools = cachedToolsList ?? [];
1019
1229
  const issues = [];
1020
1230
  for (const tool of tools) {
1021
1231
  if (!tool.name) {
@@ -1047,7 +1257,8 @@ async function runComplianceSuite(url, options = {}) {
1047
1257
  false,
1048
1258
  "server/tools#annotations",
1049
1259
  async () => {
1050
- const tools = cachedToolsList ?? (await rpc("tools/list")).body?.result?.tools ?? [];
1260
+ if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
1261
+ const tools = cachedToolsList ?? [];
1051
1262
  if (tools.length === 0) return { passed: true, details: "No tools to validate" };
1052
1263
  const issues = [];
1053
1264
  let annotatedCount = 0;
@@ -1077,7 +1288,8 @@ async function runComplianceSuite(url, options = {}) {
1077
1288
  }
1078
1289
  );
1079
1290
  await test("tools-title-field", "Tools include title field", "schema", false, "server/tools#data-types", async () => {
1080
- const tools = cachedToolsList ?? (await rpc("tools/list")).body?.result?.tools ?? [];
1291
+ if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
1292
+ const tools = cachedToolsList ?? [];
1081
1293
  if (tools.length === 0) return { passed: true, details: "No tools to validate" };
1082
1294
  const withTitle = tools.filter((t) => typeof t.title === "string");
1083
1295
  const issues = [];
@@ -1099,7 +1311,8 @@ async function runComplianceSuite(url, options = {}) {
1099
1311
  false,
1100
1312
  "server/tools#structured-content",
1101
1313
  async () => {
1102
- const tools = cachedToolsList ?? (await rpc("tools/list")).body?.result?.tools ?? [];
1314
+ if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
1315
+ const tools = cachedToolsList ?? [];
1103
1316
  if (tools.length === 0) return { passed: true, details: "No tools to validate" };
1104
1317
  const issues = [];
1105
1318
  let withSchema = 0;
@@ -1140,14 +1353,14 @@ async function runComplianceSuite(url, options = {}) {
1140
1353
  return { passed: true, details: `Protocol error: code ${code} \u2014 ${error.message}` };
1141
1354
  }
1142
1355
  if (result?.content && Array.isArray(result.content)) {
1356
+ if (result.isError) {
1357
+ return { passed: true, details: "Tool returned execution error with content (valid)" };
1358
+ }
1143
1359
  const badItems = result.content.filter((c) => !c.type);
1144
1360
  if (badItems.length > 0)
1145
1361
  return { passed: false, details: `${badItems.length} content item(s) missing 'type' field` };
1146
1362
  return { passed: true, details: `Returned ${result.content.length} content item(s)` };
1147
1363
  }
1148
- if (result?.isError && result?.content && Array.isArray(result.content)) {
1149
- return { passed: true, details: "Tool returned execution error with content (valid)" };
1150
- }
1151
1364
  return { passed: false, details: "Response missing content array" };
1152
1365
  }
1153
1366
  );
@@ -1249,6 +1462,7 @@ async function runComplianceSuite(url, options = {}) {
1249
1462
  return { passed: true, details: `${resourceCount} resource(s)` };
1250
1463
  }
1251
1464
  );
1465
+ const resourcesListOk = cachedResourcesList !== null;
1252
1466
  await test(
1253
1467
  "resources-schema",
1254
1468
  "Resources have uri and name",
@@ -1256,7 +1470,8 @@ async function runComplianceSuite(url, options = {}) {
1256
1470
  true,
1257
1471
  "server/resources#data-types",
1258
1472
  async () => {
1259
- const resources = cachedResourcesList ?? (await rpc("resources/list")).body?.result?.resources ?? [];
1473
+ if (!resourcesListOk) return { passed: false, details: "Skipped: resources/list failed" };
1474
+ const resources = cachedResourcesList ?? [];
1260
1475
  const issues = [];
1261
1476
  for (const r of resources) {
1262
1477
  if (!r.uri) issues.push("Resource missing uri");
@@ -1285,7 +1500,7 @@ async function runComplianceSuite(url, options = {}) {
1285
1500
  false,
1286
1501
  "server/resources#reading-resources",
1287
1502
  async () => {
1288
- const resources = cachedResourcesList ?? (await rpc("resources/list")).body?.result?.resources ?? [];
1503
+ const resources = cachedResourcesList ?? [];
1289
1504
  const firstUri = resources[0]?.uri;
1290
1505
  if (!firstUri) return { passed: false, details: "No resource URI to test" };
1291
1506
  const readRes = await rpc("resources/read", { uri: firstUri });
@@ -1318,8 +1533,15 @@ async function runComplianceSuite(url, options = {}) {
1318
1533
  if (!Array.isArray(templates)) return { passed: false, details: "No resourceTemplates array" };
1319
1534
  const issues = [];
1320
1535
  for (const t of templates) {
1321
- if (!t.uriTemplate) issues.push("Template missing uriTemplate");
1536
+ if (!t.uriTemplate) {
1537
+ issues.push("Template missing uriTemplate");
1538
+ } else if (typeof t.uriTemplate !== "string") {
1539
+ issues.push(`uriTemplate should be a string, got ${typeof t.uriTemplate}`);
1540
+ } else if (!t.uriTemplate.includes("{") || !t.uriTemplate.includes("}")) {
1541
+ warnings.push(`Template "${t.name || t.uriTemplate}" has no URI template parameters (e.g., {id})`);
1542
+ }
1322
1543
  if (!t.name) issues.push(`${t.uriTemplate || "?"}: missing name`);
1544
+ if (!t.description) warnings.push(`Template "${t.name || t.uriTemplate || "?"}" missing description`);
1323
1545
  }
1324
1546
  if (issues.length > 0) return { passed: false, details: issues.join("; ") };
1325
1547
  return { passed: true, details: `${templates.length} resource template(s)` };
@@ -1361,7 +1583,7 @@ async function runComplianceSuite(url, options = {}) {
1361
1583
  true,
1362
1584
  "server/resources#subscriptions",
1363
1585
  async () => {
1364
- const resources = cachedResourcesList ?? (await rpc("resources/list")).body?.result?.resources ?? [];
1586
+ const resources = cachedResourcesList ?? [];
1365
1587
  const firstUri = resources[0]?.uri;
1366
1588
  if (!firstUri) return { passed: false, details: "No resource URI for subscribe test" };
1367
1589
  const subRes = await rpc("resources/subscribe", { uri: firstUri });
@@ -1405,8 +1627,10 @@ async function runComplianceSuite(url, options = {}) {
1405
1627
  };
1406
1628
  }
1407
1629
  );
1630
+ const promptsListOk = cachedPromptsList !== null;
1408
1631
  await test("prompts-schema", "Prompts have name field", "schema", true, "server/prompts#data-types", async () => {
1409
- const prompts = cachedPromptsList ?? (await rpc("prompts/list")).body?.result?.prompts ?? [];
1632
+ if (!promptsListOk) return { passed: false, details: "Skipped: prompts/list failed" };
1633
+ const prompts = cachedPromptsList ?? [];
1410
1634
  const issues = [];
1411
1635
  for (const p of prompts) {
1412
1636
  if (!p.name) issues.push("Prompt missing name");
@@ -1602,6 +1826,60 @@ async function runComplianceSuite(url, options = {}) {
1602
1826
  }
1603
1827
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
1604
1828
  });
1829
+ await test(
1830
+ "transport-delete",
1831
+ "DELETE accepted or returns 405",
1832
+ "transport",
1833
+ false,
1834
+ "basic/transports#streamable-http",
1835
+ async () => {
1836
+ const deleteHeaders = { ...buildHeaders() };
1837
+ const res = await request(backendUrl, {
1838
+ method: "DELETE",
1839
+ headers: deleteHeaders,
1840
+ signal: AbortSignal.timeout(timeout)
1841
+ });
1842
+ await res.body.text();
1843
+ if (res.statusCode === 405) {
1844
+ return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
1845
+ }
1846
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1847
+ if (sessionId) {
1848
+ try {
1849
+ const verifyRes = await mcpRequest(
1850
+ backendUrl,
1851
+ "ping",
1852
+ void 0,
1853
+ createIdCounter(99920),
1854
+ deleteHeaders,
1855
+ timeout
1856
+ );
1857
+ if (verifyRes.statusCode === 400 || verifyRes.statusCode === 404 || verifyRes.statusCode === 409) {
1858
+ return {
1859
+ passed: true,
1860
+ details: `HTTP ${res.statusCode} (session terminated, post-delete request correctly rejected with ${verifyRes.statusCode})`
1861
+ };
1862
+ }
1863
+ } catch {
1864
+ return {
1865
+ passed: true,
1866
+ details: `HTTP ${res.statusCode} (session terminated, post-delete request rejected)`
1867
+ };
1868
+ }
1869
+ }
1870
+ return { passed: true, details: `HTTP ${res.statusCode} (session termination supported)` };
1871
+ }
1872
+ if (res.statusCode === 400 || res.statusCode === 404) {
1873
+ return { passed: true, details: `HTTP ${res.statusCode} (no active session, acceptable)` };
1874
+ }
1875
+ return { passed: false, details: `HTTP ${res.statusCode}` };
1876
+ }
1877
+ );
1878
+ const MAX_WARNINGS = 50;
1879
+ if (warnings.length > MAX_WARNINGS) {
1880
+ const truncated = warnings.length - MAX_WARNINGS;
1881
+ warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
1882
+ }
1605
1883
  const { score, grade, overall, summary, categories } = computeScore(tests);
1606
1884
  const badge = generateBadge(url);
1607
1885
  return {
@@ -1631,7 +1909,7 @@ async function runComplianceSuite(url, options = {}) {
1631
1909
  function registerTools(server) {
1632
1910
  server.tool(
1633
1911
  "mcp_compliance_test",
1634
- "Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all 43 tests covering transport, lifecycle, tools, resources, prompts, errors, and schema validation.",
1912
+ "Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all 48 tests covering transport, lifecycle, tools, resources, prompts, errors, and schema validation.",
1635
1913
  {
1636
1914
  url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
1637
1915
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
@@ -1687,8 +1965,9 @@ ${JSON.stringify(report, null, 2)}` }
1687
1965
  ]
1688
1966
  };
1689
1967
  } catch (err) {
1968
+ const message = err instanceof Error ? err.message : String(err);
1690
1969
  return {
1691
- content: [{ type: "text", text: `Error running compliance test: ${err.message}` }],
1970
+ content: [{ type: "text", text: `Error running compliance test: ${message}` }],
1692
1971
  isError: true
1693
1972
  };
1694
1973
  }
@@ -1736,8 +2015,9 @@ ${JSON.stringify(report, null, 2)}` }
1736
2015
  ]
1737
2016
  };
1738
2017
  } catch (err) {
2018
+ const message = err instanceof Error ? err.message : String(err);
1739
2019
  return {
1740
- content: [{ type: "text", text: `Error: ${err.message}` }],
2020
+ content: [{ type: "text", text: `Error: ${message}` }],
1741
2021
  isError: true
1742
2022
  };
1743
2023
  }
@@ -1781,7 +2061,7 @@ ${TEST_DEFINITIONS.map((t) => t.id).join(", ")}`
1781
2061
  `Name: ${def.name}`,
1782
2062
  `Category: ${def.category}`,
1783
2063
  `Required: ${def.required ? "Yes" : "No"}`,
1784
- `Spec reference: https://modelcontextprotocol.io/specification/2025-11-25/${def.specRef}`,
2064
+ `Spec reference: ${SPEC_BASE}/${def.specRef}`,
1785
2065
  "",
1786
2066
  def.description,
1787
2067
  "",
@@ -1950,7 +2230,6 @@ function formatJson(report) {
1950
2230
  return JSON.stringify(report, null, 2);
1951
2231
  }
1952
2232
  function formatSarif(report) {
1953
- const SPEC_BASE2 = `https://modelcontextprotocol.io/specification/${report.specVersion}`;
1954
2233
  const rules = report.tests.map((t) => {
1955
2234
  const def = TEST_DEFINITIONS.find((d) => d.id === t.id);
1956
2235
  return {
@@ -1958,7 +2237,7 @@ function formatSarif(report) {
1958
2237
  name: t.name,
1959
2238
  shortDescription: { text: t.name },
1960
2239
  fullDescription: { text: def?.description || t.details },
1961
- helpUri: t.specRef || `${SPEC_BASE2}/basic`,
2240
+ helpUri: t.specRef || `${SPEC_BASE}/basic`,
1962
2241
  properties: {
1963
2242
  category: t.category,
1964
2243
  required: t.required
@@ -2010,7 +2289,13 @@ function formatSarif(report) {
2010
2289
  grade: report.grade,
2011
2290
  score: report.score,
2012
2291
  overall: report.overall,
2013
- specVersion: report.specVersion
2292
+ specVersion: report.specVersion,
2293
+ serverUrl: report.url,
2294
+ serverName: report.serverInfo.name,
2295
+ serverVersion: report.serverInfo.version,
2296
+ protocolVersion: report.serverInfo.protocolVersion,
2297
+ testsPassed: report.summary.passed,
2298
+ testsTotal: report.summary.total
2014
2299
  }
2015
2300
  }
2016
2301
  ]
@@ -2033,12 +2318,27 @@ function parseHeaderArg(value, prev) {
2033
2318
  prev[key] = val;
2034
2319
  return prev;
2035
2320
  }
2321
+ function parsePositiveInt(value, name, min = 0) {
2322
+ const n = Number.parseInt(value, 10);
2323
+ if (Number.isNaN(n) || n < min) {
2324
+ throw new Error(`${name} must be an integer >= ${min}, got "${value}"`);
2325
+ }
2326
+ return n;
2327
+ }
2036
2328
  function parseList(value) {
2037
2329
  return value.split(",").map((s) => s.trim()).filter(Boolean);
2038
2330
  }
2039
2331
  var program = new Command();
2040
2332
  program.name("mcp-compliance").description("Test MCP servers for spec compliance").version(version2);
2041
- program.command("test").description("Run the full compliance test suite against an MCP server").argument("<url>", "MCP server URL to test").option("--format <format>", "Output format: terminal, json, or sarif", "terminal").option("--strict", "Exit with code 1 on any required test failure (for CI)").option("-H, --header <header>", 'Add header to all requests (format: "Key: Value", repeatable)', parseHeaderArg, {}).option("--auth <token>", 'Shorthand for -H "Authorization: <token>"').option("--timeout <ms>", "Request timeout in milliseconds", "15000").option("--retries <n>", "Number of retries for failed tests", "0").option("--only <items>", "Only run tests matching these categories or test IDs (comma-separated)", parseList).option("--skip <items>", "Skip tests matching these categories or test IDs (comma-separated)", parseList).option("--verbose", "Print each test result as it runs").action(
2333
+ program.command("test").description("Run the full compliance test suite against an MCP server").argument("<url>", "MCP server URL to test").option("--format <format>", "Output format: terminal, json, or sarif", "terminal").option("--strict", "Exit with code 1 on any required test failure (for CI)").option("-H, --header <header>", 'Add header to all requests (format: "Key: Value", repeatable)', parseHeaderArg, {}).option("--auth <token>", 'Shorthand for -H "Authorization: <token>"').option("--timeout <ms>", "Request timeout in milliseconds", "15000").option("--retries <n>", "Number of retries for failed tests", "0").option(
2334
+ "--only <items>",
2335
+ 'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
2336
+ parseList
2337
+ ).option(
2338
+ "--skip <items>",
2339
+ 'Skip matching categories or test IDs, comma-separated (e.g., "schema" or "tools-pagination")',
2340
+ parseList
2341
+ ).option("--verbose", "Print each test result as it runs").action(
2042
2342
  async (url, opts) => {
2043
2343
  try {
2044
2344
  const headers = { ...opts.header };
@@ -2050,8 +2350,8 @@ Testing ${url}...
2050
2350
  }
2051
2351
  const report = await runComplianceSuite(url, {
2052
2352
  headers,
2053
- timeout: Number.parseInt(opts.timeout, 10) || 15e3,
2054
- retries: Number.parseInt(opts.retries, 10) || 0,
2353
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
2354
+ retries: parsePositiveInt(opts.retries, "--retries"),
2055
2355
  only: opts.only,
2056
2356
  skip: opts.skip,
2057
2357
  onProgress: opts.verbose ? (testId, passed, details) => {
@@ -2073,11 +2373,12 @@ Testing ${url}...
2073
2373
  process.exit(1);
2074
2374
  }
2075
2375
  } catch (err) {
2376
+ const message = err instanceof Error ? err.message : String(err);
2076
2377
  if (opts.format === "json" || opts.format === "sarif") {
2077
- console.error(JSON.stringify({ error: err.message }));
2378
+ console.error(JSON.stringify({ error: message }));
2078
2379
  } else {
2079
2380
  console.error(chalk2.red(`
2080
- Error: ${err.message}
2381
+ Error: ${message}
2081
2382
  `));
2082
2383
  }
2083
2384
  process.exit(1);
@@ -2093,15 +2394,16 @@ Testing ${url}...
2093
2394
  `));
2094
2395
  const report = await runComplianceSuite(url, {
2095
2396
  headers,
2096
- timeout: Number.parseInt(opts.timeout, 10) || 15e3
2397
+ timeout: parsePositiveInt(opts.timeout, "--timeout", 1)
2097
2398
  });
2098
2399
  console.log(`Grade: ${report.grade} (${report.score}%)
2099
2400
  `);
2100
2401
  console.log(report.badge.markdown);
2101
2402
  console.log("");
2102
2403
  } catch (err) {
2404
+ const message = err instanceof Error ? err.message : String(err);
2103
2405
  console.error(chalk2.red(`
2104
- Error: ${err.message}
2406
+ Error: ${message}
2105
2407
  `));
2106
2408
  process.exit(1);
2107
2409
  }