@yawlabs/mcp-compliance 0.6.0 → 0.7.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 +21 -12
- package/dist/{chunk-QLFYT4I7.js → chunk-SELO4TOW.js} +456 -41
- package/dist/index.js +464 -47
- package/dist/mcp/server.js +5 -5
- package/dist/runner.d.ts +1 -1
- package/dist/runner.js +1 -1
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire as createRequire3 } from "module";
|
|
5
5
|
import chalk2 from "chalk";
|
|
6
|
-
import { Command } from "commander";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/mcp/server.ts
|
|
9
9
|
import { createRequire as createRequire2 } from "module";
|
|
@@ -71,7 +71,7 @@ function computeScore(tests) {
|
|
|
71
71
|
|
|
72
72
|
// src/types.ts
|
|
73
73
|
var TEST_DEFINITIONS = [
|
|
74
|
-
// ── Transport (
|
|
74
|
+
// ── Transport (13 tests) ─────────────────────────────────────────
|
|
75
75
|
{
|
|
76
76
|
id: "transport-post",
|
|
77
77
|
name: "HTTP POST accepted",
|
|
@@ -96,8 +96,8 @@ var TEST_DEFINITIONS = [
|
|
|
96
96
|
category: "transport",
|
|
97
97
|
required: false,
|
|
98
98
|
specRef: "basic/transports#streamable-http",
|
|
99
|
-
description: "Verifies that sending a JSON-RPC notification (no id field) returns HTTP 202 Accepted with no body. Per spec, servers MUST return 202
|
|
100
|
-
recommendation: "Detect JSON-RPC messages without an id field and return HTTP 202 with an empty body. Do not
|
|
99
|
+
description: "Verifies that sending a JSON-RPC notification (no id field) returns exactly HTTP 202 Accepted with no body. Per spec, servers MUST return 202 \u2014 not 200 or 204.",
|
|
100
|
+
recommendation: "Detect JSON-RPC messages without an id field and return HTTP 202 with an empty body. Do not return 200 or 204 \u2014 the spec requires exactly 202 Accepted."
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
id: "transport-session-id",
|
|
@@ -108,6 +108,24 @@ var TEST_DEFINITIONS = [
|
|
|
108
108
|
description: "Tests that the server returns HTTP 400 when MCP-Session-Id header is missing on requests after initialization (when the server issued a session ID).",
|
|
109
109
|
recommendation: "If your server issues an MCP-Session-Id header in the initialize response, reject subsequent requests that omit this header with HTTP 400."
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
id: "transport-session-invalid",
|
|
113
|
+
name: "Returns 404 for unknown session ID",
|
|
114
|
+
category: "transport",
|
|
115
|
+
required: false,
|
|
116
|
+
specRef: "basic/transports#streamable-http",
|
|
117
|
+
description: "Sends a request with a fabricated MCP-Session-Id and verifies the server returns HTTP 404. Per spec, servers managing sessions MUST return 404 for unrecognized session IDs.",
|
|
118
|
+
recommendation: "Return HTTP 404 (Not Found) for requests with an MCP-Session-Id that does not match any active session. Do not return 400 \u2014 that is for missing session IDs."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "transport-content-type-reject",
|
|
122
|
+
name: "Rejects non-JSON request Content-Type",
|
|
123
|
+
category: "transport",
|
|
124
|
+
required: false,
|
|
125
|
+
specRef: "basic/transports#streamable-http",
|
|
126
|
+
description: "Sends a POST with Content-Type: text/plain instead of application/json and verifies the server rejects it with a 4xx status.",
|
|
127
|
+
recommendation: "Validate the Content-Type header on incoming POST requests. Reject requests that are not application/json with HTTP 415 (Unsupported Media Type) or 400."
|
|
128
|
+
},
|
|
111
129
|
{
|
|
112
130
|
id: "transport-get",
|
|
113
131
|
name: "GET returns SSE stream or 405",
|
|
@@ -162,7 +180,16 @@ var TEST_DEFINITIONS = [
|
|
|
162
180
|
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
181
|
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
182
|
},
|
|
165
|
-
|
|
183
|
+
{
|
|
184
|
+
id: "transport-sse-event-field",
|
|
185
|
+
name: "SSE responses include event: message",
|
|
186
|
+
category: "transport",
|
|
187
|
+
required: false,
|
|
188
|
+
specRef: "basic/transports#streamable-http",
|
|
189
|
+
description: "Sends a request with Accept: text/event-stream and checks that SSE responses include the event: message field. Per spec, servers MUST set event: message for JSON-RPC messages in SSE streams.",
|
|
190
|
+
recommendation: 'Include "event: message" before each "data:" line in your SSE responses. This is required by the MCP spec for JSON-RPC messages sent over SSE.'
|
|
191
|
+
},
|
|
192
|
+
// ── Lifecycle (15 tests) ─────────────────────────────────────────
|
|
166
193
|
{
|
|
167
194
|
id: "lifecycle-init",
|
|
168
195
|
name: "Initialize handshake",
|
|
@@ -235,6 +262,33 @@ var TEST_DEFINITIONS = [
|
|
|
235
262
|
description: "Verifies that the JSON-RPC response id matches the request id sent by the client. This is a fundamental JSON-RPC 2.0 requirement.",
|
|
236
263
|
recommendation: "Copy the id field from the request into the response. This is a core JSON-RPC 2.0 requirement. Check that your framework does not modify or discard the request ID."
|
|
237
264
|
},
|
|
265
|
+
{
|
|
266
|
+
id: "lifecycle-string-id",
|
|
267
|
+
name: "Supports string request IDs",
|
|
268
|
+
category: "lifecycle",
|
|
269
|
+
required: false,
|
|
270
|
+
specRef: "basic",
|
|
271
|
+
description: "Sends a request with a string id instead of a number. JSON-RPC 2.0 allows both string and number IDs. The server must echo back the exact string id in the response.",
|
|
272
|
+
recommendation: "Ensure your JSON-RPC implementation supports both string and number request IDs. Echo the id back exactly as received, preserving its type."
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "lifecycle-version-negotiate",
|
|
276
|
+
name: "Handles unknown protocol version",
|
|
277
|
+
category: "lifecycle",
|
|
278
|
+
required: false,
|
|
279
|
+
specRef: "basic/lifecycle#version-negotiation",
|
|
280
|
+
description: 'Sends an initialize request with a future protocol version ("2099-01-01") and verifies the server either negotiates down to a version it supports or returns an error.',
|
|
281
|
+
recommendation: "When the client requests an unsupported protocol version, respond with the closest version your server supports. Do not blindly accept unknown versions."
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "lifecycle-reinit-reject",
|
|
285
|
+
name: "Rejects second initialize request",
|
|
286
|
+
category: "lifecycle",
|
|
287
|
+
required: false,
|
|
288
|
+
specRef: "basic/lifecycle#initialization",
|
|
289
|
+
description: "Sends a second initialize request within the same session. Per spec, the client MUST NOT send initialize more than once. The server should reject it.",
|
|
290
|
+
recommendation: "Track initialization state per session. Reject duplicate initialize requests with a JSON-RPC error or HTTP 4xx. Do not reset session state on re-initialization."
|
|
291
|
+
},
|
|
238
292
|
{
|
|
239
293
|
id: "lifecycle-logging",
|
|
240
294
|
name: "logging/setLevel accepted",
|
|
@@ -264,12 +318,12 @@ var TEST_DEFINITIONS = [
|
|
|
264
318
|
},
|
|
265
319
|
{
|
|
266
320
|
id: "lifecycle-progress",
|
|
267
|
-
name: "
|
|
321
|
+
name: "Handles progress notifications gracefully",
|
|
268
322
|
category: "lifecycle",
|
|
269
323
|
required: false,
|
|
270
324
|
specRef: "basic/utilities#progress",
|
|
271
|
-
description: "
|
|
272
|
-
recommendation: "Accept notifications
|
|
325
|
+
description: "Sends a notifications/progress to the server and verifies it does not error. Note: per spec, progress flows from server to client during long-running requests. This test validates the server handles unexpected notifications gracefully.",
|
|
326
|
+
recommendation: "Accept unknown notifications without returning an error. The server should not crash or return a non-2xx status for notifications it does not recognize."
|
|
273
327
|
},
|
|
274
328
|
// ── Tools (4 tests) ──────────────────────────────────────────────
|
|
275
329
|
{
|
|
@@ -278,7 +332,7 @@ var TEST_DEFINITIONS = [
|
|
|
278
332
|
category: "tools",
|
|
279
333
|
required: false,
|
|
280
334
|
specRef: "server/tools#listing-tools",
|
|
281
|
-
description: "Calls tools/list and validates it returns an array of tool definitions.
|
|
335
|
+
description: "Calls tools/list and validates it returns an array of tool definitions. Dynamically required at runtime if the server declares tools capability.",
|
|
282
336
|
recommendation: "Implement the tools/list handler to return { tools: [...] } with an array of tool definition objects. Each tool needs at least a name and inputSchema."
|
|
283
337
|
},
|
|
284
338
|
{
|
|
@@ -315,7 +369,7 @@ var TEST_DEFINITIONS = [
|
|
|
315
369
|
category: "resources",
|
|
316
370
|
required: false,
|
|
317
371
|
specRef: "server/resources#listing-resources",
|
|
318
|
-
description: "Calls resources/list and validates it returns an array.
|
|
372
|
+
description: "Calls resources/list and validates it returns an array. Dynamically required at runtime if the server declares resources capability.",
|
|
319
373
|
recommendation: "Implement resources/list to return { resources: [...] } with an array of resource objects. Each resource needs at least a uri and name."
|
|
320
374
|
},
|
|
321
375
|
{
|
|
@@ -361,7 +415,7 @@ var TEST_DEFINITIONS = [
|
|
|
361
415
|
category: "prompts",
|
|
362
416
|
required: false,
|
|
363
417
|
specRef: "server/prompts#listing-prompts",
|
|
364
|
-
description: "Calls prompts/list and validates it returns an array.
|
|
418
|
+
description: "Calls prompts/list and validates it returns an array. Dynamically required at runtime if the server declares prompts capability.",
|
|
365
419
|
recommendation: "Implement prompts/list to return { prompts: [...] } with an array of prompt objects. Each prompt needs at least a name field."
|
|
366
420
|
},
|
|
367
421
|
{
|
|
@@ -382,7 +436,7 @@ var TEST_DEFINITIONS = [
|
|
|
382
436
|
description: "Tests cursor-based pagination on prompts/list. Validates nextCursor is a string if present and that fetching the next page works.",
|
|
383
437
|
recommendation: "If you return nextCursor in prompts/list, ensure it is a string and that passing it back as cursor in the next request returns valid results."
|
|
384
438
|
},
|
|
385
|
-
// ── Error Handling (
|
|
439
|
+
// ── Error Handling (10 tests) ────────────────────────────────────
|
|
386
440
|
{
|
|
387
441
|
id: "error-unknown-method",
|
|
388
442
|
name: "Returns JSON-RPC error for unknown method",
|
|
@@ -455,6 +509,24 @@ var TEST_DEFINITIONS = [
|
|
|
455
509
|
description: "Calls tools/call with a nonexistent tool name and verifies the server returns an error response.",
|
|
456
510
|
recommendation: "Return a JSON-RPC error or set isError: true when tools/call receives an unrecognized tool name. Do not return an empty success response."
|
|
457
511
|
},
|
|
512
|
+
{
|
|
513
|
+
id: "error-capability-gated",
|
|
514
|
+
name: "Rejects methods for undeclared capabilities",
|
|
515
|
+
category: "errors",
|
|
516
|
+
required: false,
|
|
517
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
518
|
+
description: "Calls list methods (tools/list, resources/list, prompts/list) for capabilities the server did NOT declare, and verifies the server returns an error instead of success.",
|
|
519
|
+
recommendation: "Return a JSON-RPC error (e.g., -32601 Method not found) for methods associated with capabilities not declared in your initialize response."
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: "error-invalid-cursor",
|
|
523
|
+
name: "Handles invalid pagination cursor gracefully",
|
|
524
|
+
category: "errors",
|
|
525
|
+
required: false,
|
|
526
|
+
specRef: "basic",
|
|
527
|
+
description: "Sends a garbage pagination cursor to a list method and verifies the server handles it gracefully \u2014 either returning an error or ignoring the invalid cursor.",
|
|
528
|
+
recommendation: "Validate pagination cursors before use. Return a JSON-RPC error for unrecognized cursors, or treat invalid cursors as a request for the first page."
|
|
529
|
+
},
|
|
458
530
|
// ── Schema Validation (6 tests) ──────────────────────────────────
|
|
459
531
|
{
|
|
460
532
|
id: "tools-schema",
|
|
@@ -471,8 +543,8 @@ var TEST_DEFINITIONS = [
|
|
|
471
543
|
category: "schema",
|
|
472
544
|
required: false,
|
|
473
545
|
specRef: "server/tools#annotations",
|
|
474
|
-
description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans
|
|
475
|
-
recommendation: "If you include annotations on tools, ensure readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are booleans.
|
|
546
|
+
description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans.",
|
|
547
|
+
recommendation: "If you include annotations on tools, ensure readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are booleans. Note: title belongs on the Tool object, not inside annotations."
|
|
476
548
|
},
|
|
477
549
|
{
|
|
478
550
|
id: "tools-title-field",
|
|
@@ -510,7 +582,7 @@ var TEST_DEFINITIONS = [
|
|
|
510
582
|
description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
|
|
511
583
|
recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
|
|
512
584
|
},
|
|
513
|
-
// ── Security: Auth & Transport (
|
|
585
|
+
// ── Security: Auth & Transport (9 tests) ─────────────────────────
|
|
514
586
|
{
|
|
515
587
|
id: "security-auth-required",
|
|
516
588
|
name: "Rejects unauthenticated requests",
|
|
@@ -558,12 +630,12 @@ var TEST_DEFINITIONS = [
|
|
|
558
630
|
},
|
|
559
631
|
{
|
|
560
632
|
id: "security-oauth-metadata",
|
|
561
|
-
name: "
|
|
633
|
+
name: "Protected Resource Metadata endpoint exists",
|
|
562
634
|
category: "security",
|
|
563
635
|
required: false,
|
|
564
636
|
specRef: "basic/authorization",
|
|
565
|
-
description: "Checks for a
|
|
566
|
-
recommendation: "Publish
|
|
637
|
+
description: "Checks for a Protected Resource Metadata (RFC 9728) endpoint at /.well-known/oauth-protected-resource. Per MCP 2025-11-25, the MCP server publishes PRM with a resource identifier and authorization_servers array. Falls back to legacy /.well-known/oauth-authorization-server with a warning.",
|
|
638
|
+
recommendation: "Publish a Protected Resource Metadata document at /.well-known/oauth-protected-resource on your server's origin. Include 'resource' (your server's URL) and 'authorization_servers' (array of OAuth AS URLs). See RFC 9728."
|
|
567
639
|
},
|
|
568
640
|
{
|
|
569
641
|
id: "security-token-in-uri",
|
|
@@ -583,6 +655,15 @@ var TEST_DEFINITIONS = [
|
|
|
583
655
|
description: "If the server returns CORS headers, verifies that Access-Control-Allow-Origin is not set to wildcard (*). Wildcard CORS on an authenticated API allows cross-origin credential theft.",
|
|
584
656
|
recommendation: 'Set Access-Control-Allow-Origin to specific trusted origins, not "*". If CORS is not needed (server-to-server only), do not send CORS headers at all.'
|
|
585
657
|
},
|
|
658
|
+
{
|
|
659
|
+
id: "security-origin-validation",
|
|
660
|
+
name: "Validates Origin header on requests",
|
|
661
|
+
category: "security",
|
|
662
|
+
required: false,
|
|
663
|
+
specRef: "basic/transports#streamable-http",
|
|
664
|
+
description: "Sends a request with a suspicious Origin header (https://evil-rebinding-attack.example.com) and verifies the server rejects it. Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.",
|
|
665
|
+
recommendation: "Validate the Origin header on all incoming requests. Reject requests from untrusted origins with HTTP 403. Maintain an allowlist of permitted origins."
|
|
666
|
+
},
|
|
586
667
|
// ── Security: Input Validation (6 tests) ─────────────────────────
|
|
587
668
|
{
|
|
588
669
|
id: "security-command-injection",
|
|
@@ -768,7 +849,7 @@ function createIdCounter(start = 0) {
|
|
|
768
849
|
}
|
|
769
850
|
function parseSSEResponse(text) {
|
|
770
851
|
const lines = text.split("\n");
|
|
771
|
-
let
|
|
852
|
+
let firstJsonRpcResponse = null;
|
|
772
853
|
let currentData = [];
|
|
773
854
|
function flushEvent() {
|
|
774
855
|
if (currentData.length === 0) return;
|
|
@@ -777,8 +858,8 @@ function parseSSEResponse(text) {
|
|
|
777
858
|
if (!data.trim()) return;
|
|
778
859
|
try {
|
|
779
860
|
const parsed = JSON.parse(data);
|
|
780
|
-
if (parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
|
|
781
|
-
|
|
861
|
+
if (!firstJsonRpcResponse && parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
|
|
862
|
+
firstJsonRpcResponse = parsed;
|
|
782
863
|
}
|
|
783
864
|
} catch {
|
|
784
865
|
}
|
|
@@ -792,7 +873,7 @@ function parseSSEResponse(text) {
|
|
|
792
873
|
}
|
|
793
874
|
}
|
|
794
875
|
flushEvent();
|
|
795
|
-
return
|
|
876
|
+
return firstJsonRpcResponse;
|
|
796
877
|
}
|
|
797
878
|
async function mcpRequest(backendUrl, method, params, nextId, extraHeaders, timeout) {
|
|
798
879
|
const id = nextId();
|
|
@@ -1002,6 +1083,32 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1002
1083
|
return { passed: valid, details: `Content-Type: ${ct}` };
|
|
1003
1084
|
}
|
|
1004
1085
|
);
|
|
1086
|
+
await test(
|
|
1087
|
+
"transport-content-type-reject",
|
|
1088
|
+
"Rejects non-JSON request Content-Type",
|
|
1089
|
+
"transport",
|
|
1090
|
+
false,
|
|
1091
|
+
"basic/transports#streamable-http",
|
|
1092
|
+
async () => {
|
|
1093
|
+
const res = await request(backendUrl, {
|
|
1094
|
+
method: "POST",
|
|
1095
|
+
headers: { "Content-Type": "text/plain", Accept: "application/json, text/event-stream", ...userHeaders },
|
|
1096
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99905, method: "ping" }),
|
|
1097
|
+
signal: AbortSignal.timeout(timeout)
|
|
1098
|
+
});
|
|
1099
|
+
await res.body.text();
|
|
1100
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
1101
|
+
return { passed: true, details: `HTTP ${res.statusCode} (incorrect Content-Type rejected)` };
|
|
1102
|
+
}
|
|
1103
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1104
|
+
return {
|
|
1105
|
+
passed: false,
|
|
1106
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted text/plain Content-Type (should require application/json)`
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1110
|
+
}
|
|
1111
|
+
);
|
|
1005
1112
|
await test(
|
|
1006
1113
|
"transport-get",
|
|
1007
1114
|
"GET returns SSE stream or 405",
|
|
@@ -1016,7 +1123,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1016
1123
|
signal: AbortSignal.timeout(timeout)
|
|
1017
1124
|
});
|
|
1018
1125
|
const body = await res.body.text();
|
|
1019
|
-
const
|
|
1126
|
+
const rawCt = res.headers["content-type"];
|
|
1127
|
+
const ct = (Array.isArray(rawCt) ? rawCt[0] : rawCt || "").toLowerCase();
|
|
1020
1128
|
if (res.statusCode === 405) {
|
|
1021
1129
|
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
1022
1130
|
}
|
|
@@ -1124,6 +1232,50 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1124
1232
|
return { passed: valid, details: `Version: ${version3}` };
|
|
1125
1233
|
}
|
|
1126
1234
|
);
|
|
1235
|
+
await test(
|
|
1236
|
+
"lifecycle-version-negotiate",
|
|
1237
|
+
"Handles unknown protocol version",
|
|
1238
|
+
"lifecycle",
|
|
1239
|
+
false,
|
|
1240
|
+
"basic/lifecycle#version-negotiation",
|
|
1241
|
+
async () => {
|
|
1242
|
+
try {
|
|
1243
|
+
const futureRes = await mcpRequest(
|
|
1244
|
+
backendUrl,
|
|
1245
|
+
"initialize",
|
|
1246
|
+
{
|
|
1247
|
+
protocolVersion: "2099-01-01",
|
|
1248
|
+
capabilities: {},
|
|
1249
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1250
|
+
},
|
|
1251
|
+
createIdCounter(99960),
|
|
1252
|
+
userHeaders,
|
|
1253
|
+
timeout
|
|
1254
|
+
);
|
|
1255
|
+
const result = futureRes.body?.result;
|
|
1256
|
+
const error = futureRes.body?.error;
|
|
1257
|
+
if (error) {
|
|
1258
|
+
return {
|
|
1259
|
+
passed: true,
|
|
1260
|
+
details: `Server rejected unknown version with error: ${error.code} \u2014 ${error.message}`
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
if (result?.protocolVersion) {
|
|
1264
|
+
const offered = result.protocolVersion;
|
|
1265
|
+
if (offered === "2099-01-01") {
|
|
1266
|
+
return {
|
|
1267
|
+
passed: false,
|
|
1268
|
+
details: 'Server accepted impossible future version "2099-01-01" \u2014 should offer a version it actually supports'
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
return { passed: true, details: `Server negotiated down to ${offered} (correct)` };
|
|
1272
|
+
}
|
|
1273
|
+
return { passed: false, details: "No protocolVersion or error in response" };
|
|
1274
|
+
} catch {
|
|
1275
|
+
return { passed: true, details: "Connection rejected for unknown version (acceptable)" };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
);
|
|
1127
1279
|
await test(
|
|
1128
1280
|
"lifecycle-server-info",
|
|
1129
1281
|
"Includes serverInfo",
|
|
@@ -1195,6 +1347,73 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1195
1347
|
details: match ? `Request id=${res.requestId}, response id=${body.id} (match)` : `Request id=${res.requestId}, response id=${body.id} (MISMATCH)`
|
|
1196
1348
|
};
|
|
1197
1349
|
});
|
|
1350
|
+
await test("lifecycle-string-id", "Supports string request IDs", "lifecycle", false, "basic", async () => {
|
|
1351
|
+
const stringId = "compliance-test-string-id";
|
|
1352
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: stringId, method: "ping", params: {} });
|
|
1353
|
+
const res = await request(backendUrl, {
|
|
1354
|
+
method: "POST",
|
|
1355
|
+
headers: {
|
|
1356
|
+
"Content-Type": "application/json",
|
|
1357
|
+
Accept: "application/json, text/event-stream",
|
|
1358
|
+
...buildHeaders()
|
|
1359
|
+
},
|
|
1360
|
+
body,
|
|
1361
|
+
signal: AbortSignal.timeout(timeout)
|
|
1362
|
+
});
|
|
1363
|
+
const text = await res.body.text();
|
|
1364
|
+
const rawCtStr = res.headers["content-type"];
|
|
1365
|
+
const ct = (Array.isArray(rawCtStr) ? rawCtStr[0] : rawCtStr || "").toLowerCase();
|
|
1366
|
+
let parsed;
|
|
1367
|
+
if (ct.includes("text/event-stream")) {
|
|
1368
|
+
parsed = parseSSEResponse(text);
|
|
1369
|
+
}
|
|
1370
|
+
if (!parsed) {
|
|
1371
|
+
try {
|
|
1372
|
+
parsed = JSON.parse(text);
|
|
1373
|
+
} catch {
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (!parsed) return { passed: false, details: "Could not parse response" };
|
|
1377
|
+
if (parsed.id === stringId) {
|
|
1378
|
+
return { passed: true, details: `String id="${stringId}" echoed back correctly` };
|
|
1379
|
+
}
|
|
1380
|
+
if (parsed.id === void 0) {
|
|
1381
|
+
return { passed: false, details: "No id in response" };
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
passed: false,
|
|
1385
|
+
details: `String id="${stringId}" sent, got back id=${JSON.stringify(parsed.id)} (type: ${typeof parsed.id})`
|
|
1386
|
+
};
|
|
1387
|
+
});
|
|
1388
|
+
await test(
|
|
1389
|
+
"lifecycle-reinit-reject",
|
|
1390
|
+
"Rejects second initialize request",
|
|
1391
|
+
"lifecycle",
|
|
1392
|
+
false,
|
|
1393
|
+
"basic/lifecycle#initialization",
|
|
1394
|
+
async () => {
|
|
1395
|
+
try {
|
|
1396
|
+
const res = await rpc("initialize", {
|
|
1397
|
+
protocolVersion: SPEC_VERSION,
|
|
1398
|
+
capabilities: {},
|
|
1399
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1400
|
+
});
|
|
1401
|
+
const error = res.body?.error;
|
|
1402
|
+
if (error) {
|
|
1403
|
+
return { passed: true, details: `Re-initialization rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
1404
|
+
}
|
|
1405
|
+
if (res.statusCode >= 400) {
|
|
1406
|
+
return { passed: true, details: `HTTP ${res.statusCode} (re-initialization rejected)` };
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
passed: false,
|
|
1410
|
+
details: `Server accepted second initialize (HTTP ${res.statusCode}) \u2014 should reject duplicate initialization`
|
|
1411
|
+
};
|
|
1412
|
+
} catch {
|
|
1413
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
);
|
|
1198
1417
|
const hasLogging = !!serverInfo.capabilities.logging;
|
|
1199
1418
|
await test(
|
|
1200
1419
|
"lifecycle-logging",
|
|
@@ -1269,7 +1488,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1269
1488
|
);
|
|
1270
1489
|
await test(
|
|
1271
1490
|
"lifecycle-progress",
|
|
1272
|
-
"
|
|
1491
|
+
"Handles progress notifications gracefully",
|
|
1273
1492
|
"lifecycle",
|
|
1274
1493
|
false,
|
|
1275
1494
|
"basic/utilities#progress",
|
|
@@ -1282,9 +1501,12 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1282
1501
|
timeout
|
|
1283
1502
|
);
|
|
1284
1503
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1285
|
-
return { passed: true, details: `HTTP ${res.statusCode} (
|
|
1504
|
+
return { passed: true, details: `HTTP ${res.statusCode} (notification handled gracefully)` };
|
|
1286
1505
|
}
|
|
1287
|
-
return {
|
|
1506
|
+
return {
|
|
1507
|
+
passed: false,
|
|
1508
|
+
details: `HTTP ${res.statusCode} \u2014 server should accept unknown notifications without error`
|
|
1509
|
+
};
|
|
1288
1510
|
}
|
|
1289
1511
|
);
|
|
1290
1512
|
await test(
|
|
@@ -1326,7 +1548,11 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1326
1548
|
return { passed: true, details: "HTTP 202 Accepted (correct)" };
|
|
1327
1549
|
}
|
|
1328
1550
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1329
|
-
|
|
1551
|
+
warnings.push(`Notification returned HTTP ${res.statusCode} instead of spec-required 202 Accepted`);
|
|
1552
|
+
return {
|
|
1553
|
+
passed: false,
|
|
1554
|
+
details: `HTTP ${res.statusCode} \u2014 spec requires 202 Accepted for notifications (MUST)`
|
|
1555
|
+
};
|
|
1330
1556
|
}
|
|
1331
1557
|
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected 202 Accepted for notifications` };
|
|
1332
1558
|
}
|
|
@@ -1357,6 +1583,34 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1357
1583
|
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1358
1584
|
}
|
|
1359
1585
|
);
|
|
1586
|
+
await test(
|
|
1587
|
+
"transport-session-invalid",
|
|
1588
|
+
"Returns 404 for unknown session ID",
|
|
1589
|
+
"transport",
|
|
1590
|
+
false,
|
|
1591
|
+
"basic/transports#streamable-http",
|
|
1592
|
+
async () => {
|
|
1593
|
+
if (!sessionId) {
|
|
1594
|
+
return { passed: true, details: "Server did not issue session ID (test not applicable)" };
|
|
1595
|
+
}
|
|
1596
|
+
const fakeHeaders = {
|
|
1597
|
+
...userHeaders,
|
|
1598
|
+
"mcp-session-id": "invalid-nonexistent-session-id"
|
|
1599
|
+
};
|
|
1600
|
+
if (negotiatedProtocolVersion) fakeHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1601
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, createIdCounter(99915), fakeHeaders, timeout);
|
|
1602
|
+
if (res.statusCode === 404) {
|
|
1603
|
+
return { passed: true, details: "HTTP 404 for unknown session ID (correct per spec)" };
|
|
1604
|
+
}
|
|
1605
|
+
if (res.statusCode === 400) {
|
|
1606
|
+
return {
|
|
1607
|
+
passed: false,
|
|
1608
|
+
details: "HTTP 400 \u2014 spec requires 404 (Not Found) for unrecognized session IDs, not 400"
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 spec requires 404 for unrecognized MCP-Session-Id` };
|
|
1612
|
+
}
|
|
1613
|
+
);
|
|
1360
1614
|
await test(
|
|
1361
1615
|
"transport-get-stream",
|
|
1362
1616
|
"GET with session returns SSE or 405",
|
|
@@ -1373,7 +1627,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1373
1627
|
signal: AbortSignal.timeout(Math.min(timeout, 3e3))
|
|
1374
1628
|
});
|
|
1375
1629
|
const body = await res.body.text();
|
|
1376
|
-
const
|
|
1630
|
+
const rawCt2 = res.headers["content-type"];
|
|
1631
|
+
const ct = (Array.isArray(rawCt2) ? rawCt2[0] : rawCt2 || "").toLowerCase();
|
|
1377
1632
|
if (res.statusCode === 405) {
|
|
1378
1633
|
return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
|
|
1379
1634
|
}
|
|
@@ -1412,7 +1667,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1412
1667
|
signal: AbortSignal.timeout(timeout)
|
|
1413
1668
|
}).then(async (res) => {
|
|
1414
1669
|
const text = await res.body.text();
|
|
1415
|
-
const
|
|
1670
|
+
const rawCtConcurrent = res.headers["content-type"];
|
|
1671
|
+
const ct = (Array.isArray(rawCtConcurrent) ? rawCtConcurrent[0] : rawCtConcurrent || "").toLowerCase();
|
|
1416
1672
|
let body;
|
|
1417
1673
|
if (ct.includes("text/event-stream")) {
|
|
1418
1674
|
body = parseSSEResponse(text);
|
|
@@ -1439,6 +1695,42 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1439
1695
|
return { passed: true, details: `${results.length} concurrent requests handled correctly` };
|
|
1440
1696
|
}
|
|
1441
1697
|
);
|
|
1698
|
+
await test(
|
|
1699
|
+
"transport-sse-event-field",
|
|
1700
|
+
"SSE responses include event: message",
|
|
1701
|
+
"transport",
|
|
1702
|
+
false,
|
|
1703
|
+
"basic/transports#streamable-http",
|
|
1704
|
+
async () => {
|
|
1705
|
+
const res = await request(backendUrl, {
|
|
1706
|
+
method: "POST",
|
|
1707
|
+
headers: {
|
|
1708
|
+
"Content-Type": "application/json",
|
|
1709
|
+
Accept: "text/event-stream",
|
|
1710
|
+
...buildHeaders()
|
|
1711
|
+
},
|
|
1712
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99940)(), method: "ping" }),
|
|
1713
|
+
signal: AbortSignal.timeout(timeout)
|
|
1714
|
+
});
|
|
1715
|
+
const text = await res.body.text();
|
|
1716
|
+
const rawCtSse = res.headers["content-type"];
|
|
1717
|
+
const ct = (Array.isArray(rawCtSse) ? rawCtSse[0] : rawCtSse || "").toLowerCase();
|
|
1718
|
+
if (!ct.includes("text/event-stream")) {
|
|
1719
|
+
return { passed: true, details: "Server responded with JSON (not SSE) \u2014 event field check not applicable" };
|
|
1720
|
+
}
|
|
1721
|
+
const hasEventMessage = /^event:\s*message\s*$/m.test(text);
|
|
1722
|
+
if (hasEventMessage) {
|
|
1723
|
+
return { passed: true, details: "SSE response includes required event: message field" };
|
|
1724
|
+
}
|
|
1725
|
+
if (text.includes("data:")) {
|
|
1726
|
+
return {
|
|
1727
|
+
passed: false,
|
|
1728
|
+
details: "SSE response has data: fields but missing required event: message field (spec: MUST include event: message)"
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
return { passed: true, details: "SSE response empty or no data fields \u2014 check not applicable" };
|
|
1732
|
+
}
|
|
1733
|
+
);
|
|
1442
1734
|
const hasTools = !!serverInfo.capabilities.tools;
|
|
1443
1735
|
let cachedToolsList = null;
|
|
1444
1736
|
await test(
|
|
@@ -1520,9 +1812,6 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1520
1812
|
issues.push(`${tool.name}: annotations.${field} should be boolean, got ${typeof ann[field]}`);
|
|
1521
1813
|
}
|
|
1522
1814
|
}
|
|
1523
|
-
if (ann.title !== void 0 && typeof ann.title !== "string") {
|
|
1524
|
-
issues.push(`${tool.name}: annotations.title should be string`);
|
|
1525
|
-
}
|
|
1526
1815
|
}
|
|
1527
1816
|
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1528
1817
|
return {
|
|
@@ -2070,6 +2359,63 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2070
2359
|
}
|
|
2071
2360
|
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
|
|
2072
2361
|
});
|
|
2362
|
+
const undeclaredMethods = [
|
|
2363
|
+
{ method: "tools/list", capability: "tools", declared: hasTools },
|
|
2364
|
+
{ method: "resources/list", capability: "resources", declared: hasResources },
|
|
2365
|
+
{ method: "prompts/list", capability: "prompts", declared: hasPrompts }
|
|
2366
|
+
];
|
|
2367
|
+
const undeclared = undeclaredMethods.filter((m) => !m.declared);
|
|
2368
|
+
await test(
|
|
2369
|
+
"error-capability-gated",
|
|
2370
|
+
"Rejects methods for undeclared capabilities",
|
|
2371
|
+
"errors",
|
|
2372
|
+
false,
|
|
2373
|
+
"basic/lifecycle#capability-negotiation",
|
|
2374
|
+
async () => {
|
|
2375
|
+
if (undeclared.length === 0) {
|
|
2376
|
+
return {
|
|
2377
|
+
passed: true,
|
|
2378
|
+
details: "Server declares all capabilities (tools, resources, prompts) \u2014 no undeclared methods to test"
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
const issues = [];
|
|
2382
|
+
for (const { method, capability } of undeclared) {
|
|
2383
|
+
const res = await rpc(method);
|
|
2384
|
+
const error = res.body?.error;
|
|
2385
|
+
if (!error && res.body?.result) {
|
|
2386
|
+
issues.push(`${method} returned success despite missing ${capability} capability`);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2390
|
+
return {
|
|
2391
|
+
passed: true,
|
|
2392
|
+
details: `Tested ${undeclared.length} undeclared method(s): ${undeclared.map((m) => m.method).join(", ")} \u2014 all returned errors`
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
);
|
|
2396
|
+
const listMethodForCursor = hasTools ? "tools/list" : hasResources ? "resources/list" : hasPrompts ? "prompts/list" : null;
|
|
2397
|
+
await test(
|
|
2398
|
+
"error-invalid-cursor",
|
|
2399
|
+
"Handles invalid pagination cursor gracefully",
|
|
2400
|
+
"errors",
|
|
2401
|
+
false,
|
|
2402
|
+
"basic",
|
|
2403
|
+
async () => {
|
|
2404
|
+
if (!listMethodForCursor) {
|
|
2405
|
+
return { passed: true, details: "No list methods available to test (skipped)" };
|
|
2406
|
+
}
|
|
2407
|
+
const res = await rpc(listMethodForCursor, { cursor: "!!!invalid-garbage-cursor-$$$" });
|
|
2408
|
+
const error = res.body?.error;
|
|
2409
|
+
if (error) {
|
|
2410
|
+
return { passed: true, details: `Invalid cursor rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
2411
|
+
}
|
|
2412
|
+
const result = res.body?.result;
|
|
2413
|
+
if (result) {
|
|
2414
|
+
return { passed: true, details: "Server returned results (likely ignored invalid cursor)" };
|
|
2415
|
+
}
|
|
2416
|
+
return { passed: false, details: "No error or result for invalid cursor" };
|
|
2417
|
+
}
|
|
2418
|
+
);
|
|
2073
2419
|
const hasAuth = !!userHeaders.Authorization || !!userHeaders.authorization;
|
|
2074
2420
|
await test(
|
|
2075
2421
|
"security-auth-required",
|
|
@@ -2214,7 +2560,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2214
2560
|
);
|
|
2215
2561
|
await test(
|
|
2216
2562
|
"security-oauth-metadata",
|
|
2217
|
-
"
|
|
2563
|
+
"Protected Resource Metadata endpoint exists",
|
|
2218
2564
|
"security",
|
|
2219
2565
|
false,
|
|
2220
2566
|
"basic/authorization",
|
|
@@ -2223,9 +2569,9 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2223
2569
|
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2224
2570
|
}
|
|
2225
2571
|
const parsedUrl = new URL(url);
|
|
2226
|
-
const
|
|
2572
|
+
const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
|
|
2227
2573
|
try {
|
|
2228
|
-
const res = await request(
|
|
2574
|
+
const res = await request(prmUrl, {
|
|
2229
2575
|
method: "GET",
|
|
2230
2576
|
headers: { Accept: "application/json" },
|
|
2231
2577
|
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
@@ -2234,20 +2580,51 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2234
2580
|
if (res.statusCode === 200) {
|
|
2235
2581
|
try {
|
|
2236
2582
|
const meta = JSON.parse(text);
|
|
2237
|
-
if (meta.
|
|
2238
|
-
return { passed:
|
|
2583
|
+
if (!meta.resource) {
|
|
2584
|
+
return { passed: false, details: "PRM response missing required 'resource' field" };
|
|
2585
|
+
}
|
|
2586
|
+
if (!Array.isArray(meta.authorization_servers) || meta.authorization_servers.length === 0) {
|
|
2587
|
+
return { passed: false, details: "PRM response missing 'authorization_servers' array" };
|
|
2239
2588
|
}
|
|
2240
2589
|
return {
|
|
2241
|
-
passed:
|
|
2242
|
-
details:
|
|
2590
|
+
passed: true,
|
|
2591
|
+
details: `Protected Resource Metadata found: resource=${meta.resource}, ${meta.authorization_servers.length} auth server(s)`
|
|
2243
2592
|
};
|
|
2244
2593
|
} catch {
|
|
2245
|
-
return { passed: false, details: "
|
|
2594
|
+
return { passed: false, details: "PRM endpoint returned non-JSON response" };
|
|
2246
2595
|
}
|
|
2247
2596
|
}
|
|
2248
|
-
|
|
2597
|
+
const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
|
|
2598
|
+
try {
|
|
2599
|
+
const legacyRes = await request(legacyUrl, {
|
|
2600
|
+
method: "GET",
|
|
2601
|
+
headers: { Accept: "application/json" },
|
|
2602
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
2603
|
+
});
|
|
2604
|
+
const legacyText = await legacyRes.body.text();
|
|
2605
|
+
if (legacyRes.statusCode === 200) {
|
|
2606
|
+
try {
|
|
2607
|
+
const legacyMeta = JSON.parse(legacyText);
|
|
2608
|
+
if (legacyMeta.issuer && legacyMeta.token_endpoint) {
|
|
2609
|
+
warnings.push(
|
|
2610
|
+
"Server uses legacy /.well-known/oauth-authorization-server instead of /.well-known/oauth-protected-resource (RFC 9728). Update to PRM for 2025-11-25 compliance."
|
|
2611
|
+
);
|
|
2612
|
+
return {
|
|
2613
|
+
passed: true,
|
|
2614
|
+
details: `Legacy OAuth AS metadata found: issuer=${legacyMeta.issuer} (should migrate to PRM)`
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
} catch {
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
} catch {
|
|
2621
|
+
}
|
|
2622
|
+
return {
|
|
2623
|
+
passed: false,
|
|
2624
|
+
details: `PRM endpoint returned HTTP ${res.statusCode} and no legacy OAuth metadata found`
|
|
2625
|
+
};
|
|
2249
2626
|
} catch {
|
|
2250
|
-
return { passed: false, details: "
|
|
2627
|
+
return { passed: false, details: "PRM endpoint unreachable" };
|
|
2251
2628
|
}
|
|
2252
2629
|
}
|
|
2253
2630
|
);
|
|
@@ -2323,6 +2700,44 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2323
2700
|
}
|
|
2324
2701
|
}
|
|
2325
2702
|
);
|
|
2703
|
+
await test(
|
|
2704
|
+
"security-origin-validation",
|
|
2705
|
+
"Validates Origin header on requests",
|
|
2706
|
+
"security",
|
|
2707
|
+
false,
|
|
2708
|
+
"basic/transports#streamable-http",
|
|
2709
|
+
async () => {
|
|
2710
|
+
try {
|
|
2711
|
+
const res = await request(backendUrl, {
|
|
2712
|
+
method: "POST",
|
|
2713
|
+
headers: {
|
|
2714
|
+
"Content-Type": "application/json",
|
|
2715
|
+
Accept: "application/json, text/event-stream",
|
|
2716
|
+
Origin: "https://evil-rebinding-attack.example.com",
|
|
2717
|
+
...buildHeaders()
|
|
2718
|
+
},
|
|
2719
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99970)(), method: "ping" }),
|
|
2720
|
+
signal: AbortSignal.timeout(timeout)
|
|
2721
|
+
});
|
|
2722
|
+
await res.body.text();
|
|
2723
|
+
if (res.statusCode === 403 || res.statusCode === 401) {
|
|
2724
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
2725
|
+
}
|
|
2726
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2727
|
+
return {
|
|
2728
|
+
passed: false,
|
|
2729
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted request with untrusted Origin header (spec: MUST validate Origin for DNS rebinding protection)`
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
if (res.statusCode >= 400) {
|
|
2733
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
2734
|
+
}
|
|
2735
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
2736
|
+
} catch {
|
|
2737
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
);
|
|
2326
2741
|
async function runInjectionTest(toolName, paramName, payloads, detectPattern, label) {
|
|
2327
2742
|
const issues = [];
|
|
2328
2743
|
for (const payload of payloads) {
|
|
@@ -2843,13 +3258,13 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2843
3258
|
function registerTools(server) {
|
|
2844
3259
|
server.tool(
|
|
2845
3260
|
"mcp_compliance_test",
|
|
2846
|
-
"Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all
|
|
3261
|
+
"Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all 78 tests covering transport, lifecycle, tools, resources, prompts, errors, schema validation, and security.",
|
|
2847
3262
|
{
|
|
2848
3263
|
url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
|
|
2849
3264
|
auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
|
|
2850
3265
|
headers: z.record(z.string()).optional().describe('Additional headers to include on all requests (e.g., {"X-Api-Key": "abc"})'),
|
|
2851
|
-
timeout: z.number().optional().describe("Request timeout in milliseconds (default: 15000)"),
|
|
2852
|
-
retries: z.number().optional().describe("Number of retries for failed tests (default: 0)"),
|
|
3266
|
+
timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)"),
|
|
3267
|
+
retries: z.number().int().min(0).max(10).optional().describe("Number of retries for failed tests (default: 0, max: 10)"),
|
|
2853
3268
|
only: z.array(z.string()).optional().describe("Only run tests matching these categories or test IDs"),
|
|
2854
3269
|
skip: z.array(z.string()).optional().describe("Skip tests matching these categories or test IDs")
|
|
2855
3270
|
},
|
|
@@ -2914,7 +3329,7 @@ ${JSON.stringify(report, null, 2)}` }
|
|
|
2914
3329
|
url: z.string().url().describe("The MCP server URL to test"),
|
|
2915
3330
|
auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
|
|
2916
3331
|
headers: z.record(z.string()).optional().describe("Additional headers to include on all requests"),
|
|
2917
|
-
timeout: z.number().optional().describe("Request timeout in milliseconds (default: 15000)")
|
|
3332
|
+
timeout: z.number().int().min(1).max(3e5).optional().describe("Request timeout in milliseconds (default: 15000, max: 300000)")
|
|
2918
3333
|
},
|
|
2919
3334
|
{
|
|
2920
3335
|
title: "Get Compliance Badge",
|
|
@@ -3265,7 +3680,9 @@ function parseList(value) {
|
|
|
3265
3680
|
}
|
|
3266
3681
|
var program = new Command();
|
|
3267
3682
|
program.name("mcp-compliance").description("Test MCP servers for spec compliance").version(version2);
|
|
3268
|
-
program.command("test").description("Run the full compliance test suite against an MCP server").argument("<url>", "MCP server URL to test").
|
|
3683
|
+
program.command("test").description("Run the full compliance test suite against an MCP server").argument("<url>", "MCP server URL to test").addOption(
|
|
3684
|
+
new Option("--format <format>", "Output format").choices(["terminal", "json", "sarif"]).default("terminal")
|
|
3685
|
+
).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(
|
|
3269
3686
|
"--only <items>",
|
|
3270
3687
|
'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
|
|
3271
3688
|
parseList
|