@yawlabs/mcp-compliance 0.6.0 → 0.8.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 +24 -12
- package/dist/{chunk-QLFYT4I7.js → chunk-DOIOJVEE.js} +600 -41
- package/dist/index.js +608 -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
|
@@ -56,7 +56,7 @@ function computeScore(tests) {
|
|
|
56
56
|
|
|
57
57
|
// src/types.ts
|
|
58
58
|
var TEST_DEFINITIONS = [
|
|
59
|
-
// ── Transport (
|
|
59
|
+
// ── Transport (13 tests) ─────────────────────────────────────────
|
|
60
60
|
{
|
|
61
61
|
id: "transport-post",
|
|
62
62
|
name: "HTTP POST accepted",
|
|
@@ -81,8 +81,8 @@ var TEST_DEFINITIONS = [
|
|
|
81
81
|
category: "transport",
|
|
82
82
|
required: false,
|
|
83
83
|
specRef: "basic/transports#streamable-http",
|
|
84
|
-
description: "Verifies that sending a JSON-RPC notification (no id field) returns HTTP 202 Accepted with no body. Per spec, servers MUST return 202
|
|
85
|
-
recommendation: "Detect JSON-RPC messages without an id field and return HTTP 202 with an empty body. Do not
|
|
84
|
+
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.",
|
|
85
|
+
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."
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
id: "transport-session-id",
|
|
@@ -93,6 +93,24 @@ var TEST_DEFINITIONS = [
|
|
|
93
93
|
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).",
|
|
94
94
|
recommendation: "If your server issues an MCP-Session-Id header in the initialize response, reject subsequent requests that omit this header with HTTP 400."
|
|
95
95
|
},
|
|
96
|
+
{
|
|
97
|
+
id: "transport-session-invalid",
|
|
98
|
+
name: "Returns 404 for unknown session ID",
|
|
99
|
+
category: "transport",
|
|
100
|
+
required: false,
|
|
101
|
+
specRef: "basic/transports#streamable-http",
|
|
102
|
+
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.",
|
|
103
|
+
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."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "transport-content-type-reject",
|
|
107
|
+
name: "Rejects non-JSON request Content-Type",
|
|
108
|
+
category: "transport",
|
|
109
|
+
required: false,
|
|
110
|
+
specRef: "basic/transports#streamable-http",
|
|
111
|
+
description: "Sends a POST with Content-Type: text/plain instead of application/json and verifies the server rejects it with a 4xx status.",
|
|
112
|
+
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."
|
|
113
|
+
},
|
|
96
114
|
{
|
|
97
115
|
id: "transport-get",
|
|
98
116
|
name: "GET returns SSE stream or 405",
|
|
@@ -147,7 +165,16 @@ var TEST_DEFINITIONS = [
|
|
|
147
165
|
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.",
|
|
148
166
|
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."
|
|
149
167
|
},
|
|
150
|
-
|
|
168
|
+
{
|
|
169
|
+
id: "transport-sse-event-field",
|
|
170
|
+
name: "SSE responses include event: message",
|
|
171
|
+
category: "transport",
|
|
172
|
+
required: false,
|
|
173
|
+
specRef: "basic/transports#streamable-http",
|
|
174
|
+
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.",
|
|
175
|
+
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.'
|
|
176
|
+
},
|
|
177
|
+
// ── Lifecycle (17 tests) ─────────────────────────────────────────
|
|
151
178
|
{
|
|
152
179
|
id: "lifecycle-init",
|
|
153
180
|
name: "Initialize handshake",
|
|
@@ -220,6 +247,33 @@ var TEST_DEFINITIONS = [
|
|
|
220
247
|
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.",
|
|
221
248
|
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."
|
|
222
249
|
},
|
|
250
|
+
{
|
|
251
|
+
id: "lifecycle-string-id",
|
|
252
|
+
name: "Supports string request IDs",
|
|
253
|
+
category: "lifecycle",
|
|
254
|
+
required: false,
|
|
255
|
+
specRef: "basic",
|
|
256
|
+
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.",
|
|
257
|
+
recommendation: "Ensure your JSON-RPC implementation supports both string and number request IDs. Echo the id back exactly as received, preserving its type."
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "lifecycle-version-negotiate",
|
|
261
|
+
name: "Handles unknown protocol version",
|
|
262
|
+
category: "lifecycle",
|
|
263
|
+
required: false,
|
|
264
|
+
specRef: "basic/lifecycle#version-negotiation",
|
|
265
|
+
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.',
|
|
266
|
+
recommendation: "When the client requests an unsupported protocol version, respond with the closest version your server supports. Do not blindly accept unknown versions."
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "lifecycle-reinit-reject",
|
|
270
|
+
name: "Rejects second initialize request",
|
|
271
|
+
category: "lifecycle",
|
|
272
|
+
required: false,
|
|
273
|
+
specRef: "basic/lifecycle#initialization",
|
|
274
|
+
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.",
|
|
275
|
+
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."
|
|
276
|
+
},
|
|
223
277
|
{
|
|
224
278
|
id: "lifecycle-logging",
|
|
225
279
|
name: "logging/setLevel accepted",
|
|
@@ -249,12 +303,30 @@ var TEST_DEFINITIONS = [
|
|
|
249
303
|
},
|
|
250
304
|
{
|
|
251
305
|
id: "lifecycle-progress",
|
|
252
|
-
name: "
|
|
306
|
+
name: "Handles progress notifications gracefully",
|
|
253
307
|
category: "lifecycle",
|
|
254
308
|
required: false,
|
|
255
309
|
specRef: "basic/utilities#progress",
|
|
256
|
-
description: "
|
|
257
|
-
recommendation: "Accept notifications
|
|
310
|
+
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.",
|
|
311
|
+
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."
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "lifecycle-list-changed",
|
|
315
|
+
name: "Accepts listChanged notifications",
|
|
316
|
+
category: "lifecycle",
|
|
317
|
+
required: false,
|
|
318
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
319
|
+
description: "Sends notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed for declared capabilities and verifies the server accepts them.",
|
|
320
|
+
recommendation: "Accept listChanged notifications gracefully. When received, re-fetch the relevant list to detect changes. These notifications signal that the client's cached list may be stale."
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "lifecycle-progress-token",
|
|
324
|
+
name: "Supports progress tokens in requests",
|
|
325
|
+
category: "lifecycle",
|
|
326
|
+
required: false,
|
|
327
|
+
specRef: "basic/utilities#progress",
|
|
328
|
+
description: "Sends a tools/call request with _meta.progressToken and checks if the server sends progress notifications via SSE. Progress support is optional but recommended for long-running operations.",
|
|
329
|
+
recommendation: "When a request includes _meta.progressToken, send notifications/progress events via SSE to report progress. Include progressToken, progress (current), and optionally total fields."
|
|
258
330
|
},
|
|
259
331
|
// ── Tools (4 tests) ──────────────────────────────────────────────
|
|
260
332
|
{
|
|
@@ -263,7 +335,7 @@ var TEST_DEFINITIONS = [
|
|
|
263
335
|
category: "tools",
|
|
264
336
|
required: false,
|
|
265
337
|
specRef: "server/tools#listing-tools",
|
|
266
|
-
description: "Calls tools/list and validates it returns an array of tool definitions.
|
|
338
|
+
description: "Calls tools/list and validates it returns an array of tool definitions. Dynamically required at runtime if the server declares tools capability.",
|
|
267
339
|
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."
|
|
268
340
|
},
|
|
269
341
|
{
|
|
@@ -300,7 +372,7 @@ var TEST_DEFINITIONS = [
|
|
|
300
372
|
category: "resources",
|
|
301
373
|
required: false,
|
|
302
374
|
specRef: "server/resources#listing-resources",
|
|
303
|
-
description: "Calls resources/list and validates it returns an array.
|
|
375
|
+
description: "Calls resources/list and validates it returns an array. Dynamically required at runtime if the server declares resources capability.",
|
|
304
376
|
recommendation: "Implement resources/list to return { resources: [...] } with an array of resource objects. Each resource needs at least a uri and name."
|
|
305
377
|
},
|
|
306
378
|
{
|
|
@@ -346,7 +418,7 @@ var TEST_DEFINITIONS = [
|
|
|
346
418
|
category: "prompts",
|
|
347
419
|
required: false,
|
|
348
420
|
specRef: "server/prompts#listing-prompts",
|
|
349
|
-
description: "Calls prompts/list and validates it returns an array.
|
|
421
|
+
description: "Calls prompts/list and validates it returns an array. Dynamically required at runtime if the server declares prompts capability.",
|
|
350
422
|
recommendation: "Implement prompts/list to return { prompts: [...] } with an array of prompt objects. Each prompt needs at least a name field."
|
|
351
423
|
},
|
|
352
424
|
{
|
|
@@ -367,7 +439,7 @@ var TEST_DEFINITIONS = [
|
|
|
367
439
|
description: "Tests cursor-based pagination on prompts/list. Validates nextCursor is a string if present and that fetching the next page works.",
|
|
368
440
|
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."
|
|
369
441
|
},
|
|
370
|
-
// ── Error Handling (
|
|
442
|
+
// ── Error Handling (10 tests) ────────────────────────────────────
|
|
371
443
|
{
|
|
372
444
|
id: "error-unknown-method",
|
|
373
445
|
name: "Returns JSON-RPC error for unknown method",
|
|
@@ -440,6 +512,24 @@ var TEST_DEFINITIONS = [
|
|
|
440
512
|
description: "Calls tools/call with a nonexistent tool name and verifies the server returns an error response.",
|
|
441
513
|
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."
|
|
442
514
|
},
|
|
515
|
+
{
|
|
516
|
+
id: "error-capability-gated",
|
|
517
|
+
name: "Rejects methods for undeclared capabilities",
|
|
518
|
+
category: "errors",
|
|
519
|
+
required: false,
|
|
520
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
521
|
+
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.",
|
|
522
|
+
recommendation: "Return a JSON-RPC error (e.g., -32601 Method not found) for methods associated with capabilities not declared in your initialize response."
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: "error-invalid-cursor",
|
|
526
|
+
name: "Handles invalid pagination cursor gracefully",
|
|
527
|
+
category: "errors",
|
|
528
|
+
required: false,
|
|
529
|
+
specRef: "basic",
|
|
530
|
+
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.",
|
|
531
|
+
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."
|
|
532
|
+
},
|
|
443
533
|
// ── Schema Validation (6 tests) ──────────────────────────────────
|
|
444
534
|
{
|
|
445
535
|
id: "tools-schema",
|
|
@@ -456,8 +546,8 @@ var TEST_DEFINITIONS = [
|
|
|
456
546
|
category: "schema",
|
|
457
547
|
required: false,
|
|
458
548
|
specRef: "server/tools#annotations",
|
|
459
|
-
description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans
|
|
460
|
-
recommendation: "If you include annotations on tools, ensure readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are booleans.
|
|
549
|
+
description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans.",
|
|
550
|
+
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."
|
|
461
551
|
},
|
|
462
552
|
{
|
|
463
553
|
id: "tools-title-field",
|
|
@@ -495,7 +585,7 @@ var TEST_DEFINITIONS = [
|
|
|
495
585
|
description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
|
|
496
586
|
recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
|
|
497
587
|
},
|
|
498
|
-
// ── Security: Auth & Transport (
|
|
588
|
+
// ── Security: Auth & Transport (10 tests) ────────────────────────
|
|
499
589
|
{
|
|
500
590
|
id: "security-auth-required",
|
|
501
591
|
name: "Rejects unauthenticated requests",
|
|
@@ -505,6 +595,15 @@ var TEST_DEFINITIONS = [
|
|
|
505
595
|
description: "Sends a request without an Authorization header and verifies the server returns HTTP 401. Servers exposed over the network should require authentication.",
|
|
506
596
|
recommendation: "Implement authentication on your MCP endpoint. Return HTTP 401 Unauthorized for requests without valid credentials. Use OAuth 2.1 or Bearer tokens as recommended by the MCP spec."
|
|
507
597
|
},
|
|
598
|
+
{
|
|
599
|
+
id: "security-www-authenticate",
|
|
600
|
+
name: "401 responses include WWW-Authenticate header",
|
|
601
|
+
category: "security",
|
|
602
|
+
required: false,
|
|
603
|
+
specRef: "basic/authorization",
|
|
604
|
+
description: "When the server returns HTTP 401, checks for a WWW-Authenticate header indicating the required authentication scheme. Per HTTP spec (RFC 9110), servers SHOULD include this header.",
|
|
605
|
+
recommendation: `Include a WWW-Authenticate header in 401 responses to indicate the required auth scheme (e.g., 'WWW-Authenticate: Bearer realm="mcp"').`
|
|
606
|
+
},
|
|
508
607
|
{
|
|
509
608
|
id: "security-auth-malformed",
|
|
510
609
|
name: "Rejects malformed auth credentials",
|
|
@@ -543,12 +642,12 @@ var TEST_DEFINITIONS = [
|
|
|
543
642
|
},
|
|
544
643
|
{
|
|
545
644
|
id: "security-oauth-metadata",
|
|
546
|
-
name: "
|
|
645
|
+
name: "Protected Resource Metadata endpoint exists",
|
|
547
646
|
category: "security",
|
|
548
647
|
required: false,
|
|
549
648
|
specRef: "basic/authorization",
|
|
550
|
-
description: "Checks for a
|
|
551
|
-
recommendation: "Publish
|
|
649
|
+
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.",
|
|
650
|
+
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."
|
|
552
651
|
},
|
|
553
652
|
{
|
|
554
653
|
id: "security-token-in-uri",
|
|
@@ -568,6 +667,15 @@ var TEST_DEFINITIONS = [
|
|
|
568
667
|
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.",
|
|
569
668
|
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.'
|
|
570
669
|
},
|
|
670
|
+
{
|
|
671
|
+
id: "security-origin-validation",
|
|
672
|
+
name: "Validates Origin header on requests",
|
|
673
|
+
category: "security",
|
|
674
|
+
required: false,
|
|
675
|
+
specRef: "basic/transports#streamable-http",
|
|
676
|
+
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.",
|
|
677
|
+
recommendation: "Validate the Origin header on all incoming requests. Reject requests from untrusted origins with HTTP 403. Maintain an allowlist of permitted origins."
|
|
678
|
+
},
|
|
571
679
|
// ── Security: Input Validation (6 tests) ─────────────────────────
|
|
572
680
|
{
|
|
573
681
|
id: "security-command-injection",
|
|
@@ -753,7 +861,7 @@ function createIdCounter(start = 0) {
|
|
|
753
861
|
}
|
|
754
862
|
function parseSSEResponse(text) {
|
|
755
863
|
const lines = text.split("\n");
|
|
756
|
-
let
|
|
864
|
+
let firstJsonRpcResponse = null;
|
|
757
865
|
let currentData = [];
|
|
758
866
|
function flushEvent() {
|
|
759
867
|
if (currentData.length === 0) return;
|
|
@@ -762,8 +870,8 @@ function parseSSEResponse(text) {
|
|
|
762
870
|
if (!data.trim()) return;
|
|
763
871
|
try {
|
|
764
872
|
const parsed = JSON.parse(data);
|
|
765
|
-
if (parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
|
|
766
|
-
|
|
873
|
+
if (!firstJsonRpcResponse && parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
|
|
874
|
+
firstJsonRpcResponse = parsed;
|
|
767
875
|
}
|
|
768
876
|
} catch {
|
|
769
877
|
}
|
|
@@ -777,7 +885,7 @@ function parseSSEResponse(text) {
|
|
|
777
885
|
}
|
|
778
886
|
}
|
|
779
887
|
flushEvent();
|
|
780
|
-
return
|
|
888
|
+
return firstJsonRpcResponse;
|
|
781
889
|
}
|
|
782
890
|
async function mcpRequest(backendUrl, method, params, nextId, extraHeaders, timeout) {
|
|
783
891
|
const id = nextId();
|
|
@@ -987,6 +1095,32 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
987
1095
|
return { passed: valid, details: `Content-Type: ${ct}` };
|
|
988
1096
|
}
|
|
989
1097
|
);
|
|
1098
|
+
await test(
|
|
1099
|
+
"transport-content-type-reject",
|
|
1100
|
+
"Rejects non-JSON request Content-Type",
|
|
1101
|
+
"transport",
|
|
1102
|
+
false,
|
|
1103
|
+
"basic/transports#streamable-http",
|
|
1104
|
+
async () => {
|
|
1105
|
+
const res = await request(backendUrl, {
|
|
1106
|
+
method: "POST",
|
|
1107
|
+
headers: { "Content-Type": "text/plain", Accept: "application/json, text/event-stream", ...userHeaders },
|
|
1108
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99905, method: "ping" }),
|
|
1109
|
+
signal: AbortSignal.timeout(timeout)
|
|
1110
|
+
});
|
|
1111
|
+
await res.body.text();
|
|
1112
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
1113
|
+
return { passed: true, details: `HTTP ${res.statusCode} (incorrect Content-Type rejected)` };
|
|
1114
|
+
}
|
|
1115
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1116
|
+
return {
|
|
1117
|
+
passed: false,
|
|
1118
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted text/plain Content-Type (should require application/json)`
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1122
|
+
}
|
|
1123
|
+
);
|
|
990
1124
|
await test(
|
|
991
1125
|
"transport-get",
|
|
992
1126
|
"GET returns SSE stream or 405",
|
|
@@ -1001,7 +1135,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1001
1135
|
signal: AbortSignal.timeout(timeout)
|
|
1002
1136
|
});
|
|
1003
1137
|
const body = await res.body.text();
|
|
1004
|
-
const
|
|
1138
|
+
const rawCt = res.headers["content-type"];
|
|
1139
|
+
const ct = (Array.isArray(rawCt) ? rawCt[0] : rawCt || "").toLowerCase();
|
|
1005
1140
|
if (res.statusCode === 405) {
|
|
1006
1141
|
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
1007
1142
|
}
|
|
@@ -1109,6 +1244,50 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1109
1244
|
return { passed: valid, details: `Version: ${version}` };
|
|
1110
1245
|
}
|
|
1111
1246
|
);
|
|
1247
|
+
await test(
|
|
1248
|
+
"lifecycle-version-negotiate",
|
|
1249
|
+
"Handles unknown protocol version",
|
|
1250
|
+
"lifecycle",
|
|
1251
|
+
false,
|
|
1252
|
+
"basic/lifecycle#version-negotiation",
|
|
1253
|
+
async () => {
|
|
1254
|
+
try {
|
|
1255
|
+
const futureRes = await mcpRequest(
|
|
1256
|
+
backendUrl,
|
|
1257
|
+
"initialize",
|
|
1258
|
+
{
|
|
1259
|
+
protocolVersion: "2099-01-01",
|
|
1260
|
+
capabilities: {},
|
|
1261
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1262
|
+
},
|
|
1263
|
+
createIdCounter(99960),
|
|
1264
|
+
userHeaders,
|
|
1265
|
+
timeout
|
|
1266
|
+
);
|
|
1267
|
+
const result = futureRes.body?.result;
|
|
1268
|
+
const error = futureRes.body?.error;
|
|
1269
|
+
if (error) {
|
|
1270
|
+
return {
|
|
1271
|
+
passed: true,
|
|
1272
|
+
details: `Server rejected unknown version with error: ${error.code} \u2014 ${error.message}`
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (result?.protocolVersion) {
|
|
1276
|
+
const offered = result.protocolVersion;
|
|
1277
|
+
if (offered === "2099-01-01") {
|
|
1278
|
+
return {
|
|
1279
|
+
passed: false,
|
|
1280
|
+
details: 'Server accepted impossible future version "2099-01-01" \u2014 should offer a version it actually supports'
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
return { passed: true, details: `Server negotiated down to ${offered} (correct)` };
|
|
1284
|
+
}
|
|
1285
|
+
return { passed: false, details: "No protocolVersion or error in response" };
|
|
1286
|
+
} catch {
|
|
1287
|
+
return { passed: true, details: "Connection rejected for unknown version (acceptable)" };
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
);
|
|
1112
1291
|
await test(
|
|
1113
1292
|
"lifecycle-server-info",
|
|
1114
1293
|
"Includes serverInfo",
|
|
@@ -1180,6 +1359,73 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1180
1359
|
details: match ? `Request id=${res.requestId}, response id=${body.id} (match)` : `Request id=${res.requestId}, response id=${body.id} (MISMATCH)`
|
|
1181
1360
|
};
|
|
1182
1361
|
});
|
|
1362
|
+
await test("lifecycle-string-id", "Supports string request IDs", "lifecycle", false, "basic", async () => {
|
|
1363
|
+
const stringId = "compliance-test-string-id";
|
|
1364
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: stringId, method: "ping", params: {} });
|
|
1365
|
+
const res = await request(backendUrl, {
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: {
|
|
1368
|
+
"Content-Type": "application/json",
|
|
1369
|
+
Accept: "application/json, text/event-stream",
|
|
1370
|
+
...buildHeaders()
|
|
1371
|
+
},
|
|
1372
|
+
body,
|
|
1373
|
+
signal: AbortSignal.timeout(timeout)
|
|
1374
|
+
});
|
|
1375
|
+
const text = await res.body.text();
|
|
1376
|
+
const rawCtStr = res.headers["content-type"];
|
|
1377
|
+
const ct = (Array.isArray(rawCtStr) ? rawCtStr[0] : rawCtStr || "").toLowerCase();
|
|
1378
|
+
let parsed;
|
|
1379
|
+
if (ct.includes("text/event-stream")) {
|
|
1380
|
+
parsed = parseSSEResponse(text);
|
|
1381
|
+
}
|
|
1382
|
+
if (!parsed) {
|
|
1383
|
+
try {
|
|
1384
|
+
parsed = JSON.parse(text);
|
|
1385
|
+
} catch {
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (!parsed) return { passed: false, details: "Could not parse response" };
|
|
1389
|
+
if (parsed.id === stringId) {
|
|
1390
|
+
return { passed: true, details: `String id="${stringId}" echoed back correctly` };
|
|
1391
|
+
}
|
|
1392
|
+
if (parsed.id === void 0) {
|
|
1393
|
+
return { passed: false, details: "No id in response" };
|
|
1394
|
+
}
|
|
1395
|
+
return {
|
|
1396
|
+
passed: false,
|
|
1397
|
+
details: `String id="${stringId}" sent, got back id=${JSON.stringify(parsed.id)} (type: ${typeof parsed.id})`
|
|
1398
|
+
};
|
|
1399
|
+
});
|
|
1400
|
+
await test(
|
|
1401
|
+
"lifecycle-reinit-reject",
|
|
1402
|
+
"Rejects second initialize request",
|
|
1403
|
+
"lifecycle",
|
|
1404
|
+
false,
|
|
1405
|
+
"basic/lifecycle#initialization",
|
|
1406
|
+
async () => {
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await rpc("initialize", {
|
|
1409
|
+
protocolVersion: SPEC_VERSION,
|
|
1410
|
+
capabilities: {},
|
|
1411
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1412
|
+
});
|
|
1413
|
+
const error = res.body?.error;
|
|
1414
|
+
if (error) {
|
|
1415
|
+
return { passed: true, details: `Re-initialization rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
1416
|
+
}
|
|
1417
|
+
if (res.statusCode >= 400) {
|
|
1418
|
+
return { passed: true, details: `HTTP ${res.statusCode} (re-initialization rejected)` };
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
passed: false,
|
|
1422
|
+
details: `Server accepted second initialize (HTTP ${res.statusCode}) \u2014 should reject duplicate initialization`
|
|
1423
|
+
};
|
|
1424
|
+
} catch {
|
|
1425
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
);
|
|
1183
1429
|
const hasLogging = !!serverInfo.capabilities.logging;
|
|
1184
1430
|
await test(
|
|
1185
1431
|
"lifecycle-logging",
|
|
@@ -1254,7 +1500,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1254
1500
|
);
|
|
1255
1501
|
await test(
|
|
1256
1502
|
"lifecycle-progress",
|
|
1257
|
-
"
|
|
1503
|
+
"Handles progress notifications gracefully",
|
|
1258
1504
|
"lifecycle",
|
|
1259
1505
|
false,
|
|
1260
1506
|
"basic/utilities#progress",
|
|
@@ -1267,9 +1513,96 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1267
1513
|
timeout
|
|
1268
1514
|
);
|
|
1269
1515
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1270
|
-
return { passed: true, details: `HTTP ${res.statusCode} (
|
|
1516
|
+
return { passed: true, details: `HTTP ${res.statusCode} (notification handled gracefully)` };
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
passed: false,
|
|
1520
|
+
details: `HTTP ${res.statusCode} \u2014 server should accept unknown notifications without error`
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
);
|
|
1524
|
+
await test(
|
|
1525
|
+
"lifecycle-list-changed",
|
|
1526
|
+
"Accepts listChanged notifications",
|
|
1527
|
+
"lifecycle",
|
|
1528
|
+
false,
|
|
1529
|
+
"basic/lifecycle#capability-negotiation",
|
|
1530
|
+
async () => {
|
|
1531
|
+
const notifications = [
|
|
1532
|
+
{ method: "notifications/tools/list_changed", gate: hasTools },
|
|
1533
|
+
{ method: "notifications/resources/list_changed", gate: hasResources },
|
|
1534
|
+
{ method: "notifications/prompts/list_changed", gate: hasPrompts }
|
|
1535
|
+
];
|
|
1536
|
+
const applicable = notifications.filter((n) => n.gate);
|
|
1537
|
+
if (applicable.length === 0) {
|
|
1538
|
+
return { passed: true, details: "No capabilities declared \u2014 listChanged notifications not applicable" };
|
|
1539
|
+
}
|
|
1540
|
+
const issues = [];
|
|
1541
|
+
for (const { method } of applicable) {
|
|
1542
|
+
try {
|
|
1543
|
+
const res = await mcpNotification(backendUrl, method, void 0, buildHeaders(), timeout);
|
|
1544
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1545
|
+
issues.push(`${method}: HTTP ${res.statusCode}`);
|
|
1546
|
+
}
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
issues.push(`${method}: ${err instanceof Error ? err.message : "error"}`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1552
|
+
return {
|
|
1553
|
+
passed: true,
|
|
1554
|
+
details: `${applicable.length} listChanged notification(s) accepted: ${applicable.map((n) => n.method).join(", ")}`
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
);
|
|
1558
|
+
await test(
|
|
1559
|
+
"lifecycle-progress-token",
|
|
1560
|
+
"Supports progress tokens in requests",
|
|
1561
|
+
"lifecycle",
|
|
1562
|
+
false,
|
|
1563
|
+
"basic/utilities#progress",
|
|
1564
|
+
async () => {
|
|
1565
|
+
if (!hasTools || toolNames.length === 0) {
|
|
1566
|
+
return { passed: true, details: "No tools available for progress token test (skipped)" };
|
|
1567
|
+
}
|
|
1568
|
+
const progressToken = "compliance-progress-test";
|
|
1569
|
+
const reqBody = JSON.stringify({
|
|
1570
|
+
jsonrpc: "2.0",
|
|
1571
|
+
id: nextId(),
|
|
1572
|
+
method: "tools/call",
|
|
1573
|
+
params: {
|
|
1574
|
+
name: toolNames[0],
|
|
1575
|
+
arguments: {},
|
|
1576
|
+
_meta: { progressToken }
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
try {
|
|
1580
|
+
const res = await request(backendUrl, {
|
|
1581
|
+
method: "POST",
|
|
1582
|
+
headers: {
|
|
1583
|
+
"Content-Type": "application/json",
|
|
1584
|
+
Accept: "text/event-stream",
|
|
1585
|
+
...buildHeaders()
|
|
1586
|
+
},
|
|
1587
|
+
body: reqBody,
|
|
1588
|
+
signal: AbortSignal.timeout(timeout)
|
|
1589
|
+
});
|
|
1590
|
+
const text = await res.body.text();
|
|
1591
|
+
const rawCtProgress = res.headers["content-type"];
|
|
1592
|
+
const ct = (Array.isArray(rawCtProgress) ? rawCtProgress[0] : rawCtProgress || "").toLowerCase();
|
|
1593
|
+
if (ct.includes("text/event-stream") && text.includes("notifications/progress")) {
|
|
1594
|
+
return { passed: true, details: "Server sent progress notifications via SSE with progressToken" };
|
|
1595
|
+
}
|
|
1596
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1597
|
+
return {
|
|
1598
|
+
passed: true,
|
|
1599
|
+
details: "Server accepted request with progressToken (no progress events observed \u2014 optional)"
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 request with progressToken accepted` };
|
|
1603
|
+
} catch {
|
|
1604
|
+
return { passed: true, details: "Request with progressToken handled (no progress events observed \u2014 optional)" };
|
|
1271
1605
|
}
|
|
1272
|
-
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept progress notifications` };
|
|
1273
1606
|
}
|
|
1274
1607
|
);
|
|
1275
1608
|
await test(
|
|
@@ -1311,7 +1644,11 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1311
1644
|
return { passed: true, details: "HTTP 202 Accepted (correct)" };
|
|
1312
1645
|
}
|
|
1313
1646
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1314
|
-
|
|
1647
|
+
warnings.push(`Notification returned HTTP ${res.statusCode} instead of spec-required 202 Accepted`);
|
|
1648
|
+
return {
|
|
1649
|
+
passed: false,
|
|
1650
|
+
details: `HTTP ${res.statusCode} \u2014 spec requires 202 Accepted for notifications (MUST)`
|
|
1651
|
+
};
|
|
1315
1652
|
}
|
|
1316
1653
|
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected 202 Accepted for notifications` };
|
|
1317
1654
|
}
|
|
@@ -1342,6 +1679,34 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1342
1679
|
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1343
1680
|
}
|
|
1344
1681
|
);
|
|
1682
|
+
await test(
|
|
1683
|
+
"transport-session-invalid",
|
|
1684
|
+
"Returns 404 for unknown session ID",
|
|
1685
|
+
"transport",
|
|
1686
|
+
false,
|
|
1687
|
+
"basic/transports#streamable-http",
|
|
1688
|
+
async () => {
|
|
1689
|
+
if (!sessionId) {
|
|
1690
|
+
return { passed: true, details: "Server did not issue session ID (test not applicable)" };
|
|
1691
|
+
}
|
|
1692
|
+
const fakeHeaders = {
|
|
1693
|
+
...userHeaders,
|
|
1694
|
+
"mcp-session-id": "invalid-nonexistent-session-id"
|
|
1695
|
+
};
|
|
1696
|
+
if (negotiatedProtocolVersion) fakeHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1697
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, createIdCounter(99915), fakeHeaders, timeout);
|
|
1698
|
+
if (res.statusCode === 404) {
|
|
1699
|
+
return { passed: true, details: "HTTP 404 for unknown session ID (correct per spec)" };
|
|
1700
|
+
}
|
|
1701
|
+
if (res.statusCode === 400) {
|
|
1702
|
+
return {
|
|
1703
|
+
passed: false,
|
|
1704
|
+
details: "HTTP 400 \u2014 spec requires 404 (Not Found) for unrecognized session IDs, not 400"
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 spec requires 404 for unrecognized MCP-Session-Id` };
|
|
1708
|
+
}
|
|
1709
|
+
);
|
|
1345
1710
|
await test(
|
|
1346
1711
|
"transport-get-stream",
|
|
1347
1712
|
"GET with session returns SSE or 405",
|
|
@@ -1358,7 +1723,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1358
1723
|
signal: AbortSignal.timeout(Math.min(timeout, 3e3))
|
|
1359
1724
|
});
|
|
1360
1725
|
const body = await res.body.text();
|
|
1361
|
-
const
|
|
1726
|
+
const rawCt2 = res.headers["content-type"];
|
|
1727
|
+
const ct = (Array.isArray(rawCt2) ? rawCt2[0] : rawCt2 || "").toLowerCase();
|
|
1362
1728
|
if (res.statusCode === 405) {
|
|
1363
1729
|
return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
|
|
1364
1730
|
}
|
|
@@ -1397,7 +1763,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1397
1763
|
signal: AbortSignal.timeout(timeout)
|
|
1398
1764
|
}).then(async (res) => {
|
|
1399
1765
|
const text = await res.body.text();
|
|
1400
|
-
const
|
|
1766
|
+
const rawCtConcurrent = res.headers["content-type"];
|
|
1767
|
+
const ct = (Array.isArray(rawCtConcurrent) ? rawCtConcurrent[0] : rawCtConcurrent || "").toLowerCase();
|
|
1401
1768
|
let body;
|
|
1402
1769
|
if (ct.includes("text/event-stream")) {
|
|
1403
1770
|
body = parseSSEResponse(text);
|
|
@@ -1424,6 +1791,42 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1424
1791
|
return { passed: true, details: `${results.length} concurrent requests handled correctly` };
|
|
1425
1792
|
}
|
|
1426
1793
|
);
|
|
1794
|
+
await test(
|
|
1795
|
+
"transport-sse-event-field",
|
|
1796
|
+
"SSE responses include event: message",
|
|
1797
|
+
"transport",
|
|
1798
|
+
false,
|
|
1799
|
+
"basic/transports#streamable-http",
|
|
1800
|
+
async () => {
|
|
1801
|
+
const res = await request(backendUrl, {
|
|
1802
|
+
method: "POST",
|
|
1803
|
+
headers: {
|
|
1804
|
+
"Content-Type": "application/json",
|
|
1805
|
+
Accept: "text/event-stream",
|
|
1806
|
+
...buildHeaders()
|
|
1807
|
+
},
|
|
1808
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99940)(), method: "ping" }),
|
|
1809
|
+
signal: AbortSignal.timeout(timeout)
|
|
1810
|
+
});
|
|
1811
|
+
const text = await res.body.text();
|
|
1812
|
+
const rawCtSse = res.headers["content-type"];
|
|
1813
|
+
const ct = (Array.isArray(rawCtSse) ? rawCtSse[0] : rawCtSse || "").toLowerCase();
|
|
1814
|
+
if (!ct.includes("text/event-stream")) {
|
|
1815
|
+
return { passed: true, details: "Server responded with JSON (not SSE) \u2014 event field check not applicable" };
|
|
1816
|
+
}
|
|
1817
|
+
const hasEventMessage = /^event:\s*message\s*$/m.test(text);
|
|
1818
|
+
if (hasEventMessage) {
|
|
1819
|
+
return { passed: true, details: "SSE response includes required event: message field" };
|
|
1820
|
+
}
|
|
1821
|
+
if (text.includes("data:")) {
|
|
1822
|
+
return {
|
|
1823
|
+
passed: false,
|
|
1824
|
+
details: "SSE response has data: fields but missing required event: message field (spec: MUST include event: message)"
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
return { passed: true, details: "SSE response empty or no data fields \u2014 check not applicable" };
|
|
1828
|
+
}
|
|
1829
|
+
);
|
|
1427
1830
|
const hasTools = !!serverInfo.capabilities.tools;
|
|
1428
1831
|
let cachedToolsList = null;
|
|
1429
1832
|
await test(
|
|
@@ -1505,9 +1908,6 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1505
1908
|
issues.push(`${tool.name}: annotations.${field} should be boolean, got ${typeof ann[field]}`);
|
|
1506
1909
|
}
|
|
1507
1910
|
}
|
|
1508
|
-
if (ann.title !== void 0 && typeof ann.title !== "string") {
|
|
1509
|
-
issues.push(`${tool.name}: annotations.title should be string`);
|
|
1510
|
-
}
|
|
1511
1911
|
}
|
|
1512
1912
|
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1513
1913
|
return {
|
|
@@ -2055,6 +2455,63 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2055
2455
|
}
|
|
2056
2456
|
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
|
|
2057
2457
|
});
|
|
2458
|
+
const undeclaredMethods = [
|
|
2459
|
+
{ method: "tools/list", capability: "tools", declared: hasTools },
|
|
2460
|
+
{ method: "resources/list", capability: "resources", declared: hasResources },
|
|
2461
|
+
{ method: "prompts/list", capability: "prompts", declared: hasPrompts }
|
|
2462
|
+
];
|
|
2463
|
+
const undeclared = undeclaredMethods.filter((m) => !m.declared);
|
|
2464
|
+
await test(
|
|
2465
|
+
"error-capability-gated",
|
|
2466
|
+
"Rejects methods for undeclared capabilities",
|
|
2467
|
+
"errors",
|
|
2468
|
+
false,
|
|
2469
|
+
"basic/lifecycle#capability-negotiation",
|
|
2470
|
+
async () => {
|
|
2471
|
+
if (undeclared.length === 0) {
|
|
2472
|
+
return {
|
|
2473
|
+
passed: true,
|
|
2474
|
+
details: "Server declares all capabilities (tools, resources, prompts) \u2014 no undeclared methods to test"
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
const issues = [];
|
|
2478
|
+
for (const { method, capability } of undeclared) {
|
|
2479
|
+
const res = await rpc(method);
|
|
2480
|
+
const error = res.body?.error;
|
|
2481
|
+
if (!error && res.body?.result) {
|
|
2482
|
+
issues.push(`${method} returned success despite missing ${capability} capability`);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2486
|
+
return {
|
|
2487
|
+
passed: true,
|
|
2488
|
+
details: `Tested ${undeclared.length} undeclared method(s): ${undeclared.map((m) => m.method).join(", ")} \u2014 all returned errors`
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
);
|
|
2492
|
+
const listMethodForCursor = hasTools ? "tools/list" : hasResources ? "resources/list" : hasPrompts ? "prompts/list" : null;
|
|
2493
|
+
await test(
|
|
2494
|
+
"error-invalid-cursor",
|
|
2495
|
+
"Handles invalid pagination cursor gracefully",
|
|
2496
|
+
"errors",
|
|
2497
|
+
false,
|
|
2498
|
+
"basic",
|
|
2499
|
+
async () => {
|
|
2500
|
+
if (!listMethodForCursor) {
|
|
2501
|
+
return { passed: true, details: "No list methods available to test (skipped)" };
|
|
2502
|
+
}
|
|
2503
|
+
const res = await rpc(listMethodForCursor, { cursor: "!!!invalid-garbage-cursor-$$$" });
|
|
2504
|
+
const error = res.body?.error;
|
|
2505
|
+
if (error) {
|
|
2506
|
+
return { passed: true, details: `Invalid cursor rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
2507
|
+
}
|
|
2508
|
+
const result = res.body?.result;
|
|
2509
|
+
if (result) {
|
|
2510
|
+
return { passed: true, details: "Server returned results (likely ignored invalid cursor)" };
|
|
2511
|
+
}
|
|
2512
|
+
return { passed: false, details: "No error or result for invalid cursor" };
|
|
2513
|
+
}
|
|
2514
|
+
);
|
|
2058
2515
|
const hasAuth = !!userHeaders.Authorization || !!userHeaders.authorization;
|
|
2059
2516
|
await test(
|
|
2060
2517
|
"security-auth-required",
|
|
@@ -2082,6 +2539,39 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2082
2539
|
}
|
|
2083
2540
|
}
|
|
2084
2541
|
);
|
|
2542
|
+
await test(
|
|
2543
|
+
"security-www-authenticate",
|
|
2544
|
+
"401 responses include WWW-Authenticate header",
|
|
2545
|
+
"security",
|
|
2546
|
+
false,
|
|
2547
|
+
"basic/authorization",
|
|
2548
|
+
async () => {
|
|
2549
|
+
if (!hasAuth) {
|
|
2550
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2551
|
+
}
|
|
2552
|
+
const noAuthHeaders = {};
|
|
2553
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
2554
|
+
try {
|
|
2555
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
2556
|
+
if (res.statusCode === 401) {
|
|
2557
|
+
const wwwAuth = res.headers["www-authenticate"];
|
|
2558
|
+
if (wwwAuth) {
|
|
2559
|
+
return { passed: true, details: `WWW-Authenticate: ${wwwAuth}` };
|
|
2560
|
+
}
|
|
2561
|
+
return {
|
|
2562
|
+
passed: false,
|
|
2563
|
+
details: "HTTP 401 but missing WWW-Authenticate header (spec: SHOULD include to indicate required auth scheme)"
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
if (res.statusCode === 403) {
|
|
2567
|
+
return { passed: true, details: "HTTP 403 (WWW-Authenticate not applicable for 403)" };
|
|
2568
|
+
}
|
|
2569
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 not a 401 response` };
|
|
2570
|
+
} catch {
|
|
2571
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
);
|
|
2085
2575
|
await test(
|
|
2086
2576
|
"security-auth-malformed",
|
|
2087
2577
|
"Rejects malformed auth credentials",
|
|
@@ -2199,7 +2689,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2199
2689
|
);
|
|
2200
2690
|
await test(
|
|
2201
2691
|
"security-oauth-metadata",
|
|
2202
|
-
"
|
|
2692
|
+
"Protected Resource Metadata endpoint exists",
|
|
2203
2693
|
"security",
|
|
2204
2694
|
false,
|
|
2205
2695
|
"basic/authorization",
|
|
@@ -2208,9 +2698,9 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2208
2698
|
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2209
2699
|
}
|
|
2210
2700
|
const parsedUrl = new URL(url);
|
|
2211
|
-
const
|
|
2701
|
+
const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
|
|
2212
2702
|
try {
|
|
2213
|
-
const res = await request(
|
|
2703
|
+
const res = await request(prmUrl, {
|
|
2214
2704
|
method: "GET",
|
|
2215
2705
|
headers: { Accept: "application/json" },
|
|
2216
2706
|
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
@@ -2219,20 +2709,51 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2219
2709
|
if (res.statusCode === 200) {
|
|
2220
2710
|
try {
|
|
2221
2711
|
const meta = JSON.parse(text);
|
|
2222
|
-
if (meta.
|
|
2223
|
-
return { passed:
|
|
2712
|
+
if (!meta.resource) {
|
|
2713
|
+
return { passed: false, details: "PRM response missing required 'resource' field" };
|
|
2714
|
+
}
|
|
2715
|
+
if (!Array.isArray(meta.authorization_servers) || meta.authorization_servers.length === 0) {
|
|
2716
|
+
return { passed: false, details: "PRM response missing 'authorization_servers' array" };
|
|
2224
2717
|
}
|
|
2225
2718
|
return {
|
|
2226
|
-
passed:
|
|
2227
|
-
details:
|
|
2719
|
+
passed: true,
|
|
2720
|
+
details: `Protected Resource Metadata found: resource=${meta.resource}, ${meta.authorization_servers.length} auth server(s)`
|
|
2228
2721
|
};
|
|
2229
2722
|
} catch {
|
|
2230
|
-
return { passed: false, details: "
|
|
2723
|
+
return { passed: false, details: "PRM endpoint returned non-JSON response" };
|
|
2231
2724
|
}
|
|
2232
2725
|
}
|
|
2233
|
-
|
|
2726
|
+
const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
|
|
2727
|
+
try {
|
|
2728
|
+
const legacyRes = await request(legacyUrl, {
|
|
2729
|
+
method: "GET",
|
|
2730
|
+
headers: { Accept: "application/json" },
|
|
2731
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
2732
|
+
});
|
|
2733
|
+
const legacyText = await legacyRes.body.text();
|
|
2734
|
+
if (legacyRes.statusCode === 200) {
|
|
2735
|
+
try {
|
|
2736
|
+
const legacyMeta = JSON.parse(legacyText);
|
|
2737
|
+
if (legacyMeta.issuer && legacyMeta.token_endpoint) {
|
|
2738
|
+
warnings.push(
|
|
2739
|
+
"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."
|
|
2740
|
+
);
|
|
2741
|
+
return {
|
|
2742
|
+
passed: true,
|
|
2743
|
+
details: `Legacy OAuth AS metadata found: issuer=${legacyMeta.issuer} (should migrate to PRM)`
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
} catch {
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
} catch {
|
|
2750
|
+
}
|
|
2751
|
+
return {
|
|
2752
|
+
passed: false,
|
|
2753
|
+
details: `PRM endpoint returned HTTP ${res.statusCode} and no legacy OAuth metadata found`
|
|
2754
|
+
};
|
|
2234
2755
|
} catch {
|
|
2235
|
-
return { passed: false, details: "
|
|
2756
|
+
return { passed: false, details: "PRM endpoint unreachable" };
|
|
2236
2757
|
}
|
|
2237
2758
|
}
|
|
2238
2759
|
);
|
|
@@ -2308,6 +2829,44 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
2308
2829
|
}
|
|
2309
2830
|
}
|
|
2310
2831
|
);
|
|
2832
|
+
await test(
|
|
2833
|
+
"security-origin-validation",
|
|
2834
|
+
"Validates Origin header on requests",
|
|
2835
|
+
"security",
|
|
2836
|
+
false,
|
|
2837
|
+
"basic/transports#streamable-http",
|
|
2838
|
+
async () => {
|
|
2839
|
+
try {
|
|
2840
|
+
const res = await request(backendUrl, {
|
|
2841
|
+
method: "POST",
|
|
2842
|
+
headers: {
|
|
2843
|
+
"Content-Type": "application/json",
|
|
2844
|
+
Accept: "application/json, text/event-stream",
|
|
2845
|
+
Origin: "https://evil-rebinding-attack.example.com",
|
|
2846
|
+
...buildHeaders()
|
|
2847
|
+
},
|
|
2848
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99970)(), method: "ping" }),
|
|
2849
|
+
signal: AbortSignal.timeout(timeout)
|
|
2850
|
+
});
|
|
2851
|
+
await res.body.text();
|
|
2852
|
+
if (res.statusCode === 403 || res.statusCode === 401) {
|
|
2853
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
2854
|
+
}
|
|
2855
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2856
|
+
return {
|
|
2857
|
+
passed: false,
|
|
2858
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted request with untrusted Origin header (spec: MUST validate Origin for DNS rebinding protection)`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
if (res.statusCode >= 400) {
|
|
2862
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
2863
|
+
}
|
|
2864
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
2865
|
+
} catch {
|
|
2866
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
);
|
|
2311
2870
|
async function runInjectionTest(toolName, paramName, payloads, detectPattern, label) {
|
|
2312
2871
|
const issues = [];
|
|
2313
2872
|
for (const payload of payloads) {
|