@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/README.md +2 -2
- package/dist/{chunk-KNOSZ3TD.js → chunk-Z7VLPYIO.js} +343 -62
- package/dist/index.js +378 -76
- package/dist/mcp/server.js +8 -5
- package/dist/runner.d.ts +12 -2
- package/dist/runner.js +7 -1
- package/package.json +1 -1
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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)
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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:
|
|
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 || `${
|
|
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(
|
|
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:
|
|
2054
|
-
retries:
|
|
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:
|
|
2378
|
+
console.error(JSON.stringify({ error: message }));
|
|
2078
2379
|
} else {
|
|
2079
2380
|
console.error(chalk2.red(`
|
|
2080
|
-
Error: ${
|
|
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:
|
|
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: ${
|
|
2406
|
+
Error: ${message}
|
|
2105
2407
|
`));
|
|
2106
2408
|
process.exit(1);
|
|
2107
2409
|
}
|