@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.
@@ -56,7 +56,7 @@ function computeScore(tests) {
56
56
 
57
57
  // src/types.ts
58
58
  var TEST_DEFINITIONS = [
59
- // ── Transport (10 tests) ─────────────────────────────────────────
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 for notifications.",
85
- recommendation: "Detect JSON-RPC messages without an id field and return HTTP 202 with an empty body. Do not attempt to send a JSON-RPC response for notifications."
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
- // ── Lifecycle (12 tests) ─────────────────────────────────────────
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 (15 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,12 @@ var TEST_DEFINITIONS = [
249
303
  },
250
304
  {
251
305
  id: "lifecycle-progress",
252
- name: "Accepts progress notifications",
306
+ name: "Handles progress notifications gracefully",
253
307
  category: "lifecycle",
254
308
  required: false,
255
309
  specRef: "basic/utilities#progress",
256
- description: "Tests that the server accepts notifications/progress without error. Servers should handle progress notifications for request tracking.",
257
- recommendation: "Accept notifications/progress with progressToken, progress, and optional total fields. Ignore notifications for unknown progress tokens."
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."
258
312
  },
259
313
  // ── Tools (4 tests) ──────────────────────────────────────────────
260
314
  {
@@ -263,7 +317,7 @@ var TEST_DEFINITIONS = [
263
317
  category: "tools",
264
318
  required: false,
265
319
  specRef: "server/tools#listing-tools",
266
- description: "Calls tools/list and validates it returns an array of tool definitions. Required if the server declares tools capability.",
320
+ description: "Calls tools/list and validates it returns an array of tool definitions. Dynamically required at runtime if the server declares tools capability.",
267
321
  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
322
  },
269
323
  {
@@ -300,7 +354,7 @@ var TEST_DEFINITIONS = [
300
354
  category: "resources",
301
355
  required: false,
302
356
  specRef: "server/resources#listing-resources",
303
- description: "Calls resources/list and validates it returns an array. Required if the server declares resources capability.",
357
+ description: "Calls resources/list and validates it returns an array. Dynamically required at runtime if the server declares resources capability.",
304
358
  recommendation: "Implement resources/list to return { resources: [...] } with an array of resource objects. Each resource needs at least a uri and name."
305
359
  },
306
360
  {
@@ -346,7 +400,7 @@ var TEST_DEFINITIONS = [
346
400
  category: "prompts",
347
401
  required: false,
348
402
  specRef: "server/prompts#listing-prompts",
349
- description: "Calls prompts/list and validates it returns an array. Required if the server declares prompts capability.",
403
+ description: "Calls prompts/list and validates it returns an array. Dynamically required at runtime if the server declares prompts capability.",
350
404
  recommendation: "Implement prompts/list to return { prompts: [...] } with an array of prompt objects. Each prompt needs at least a name field."
351
405
  },
352
406
  {
@@ -367,7 +421,7 @@ var TEST_DEFINITIONS = [
367
421
  description: "Tests cursor-based pagination on prompts/list. Validates nextCursor is a string if present and that fetching the next page works.",
368
422
  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
423
  },
370
- // ── Error Handling (8 tests) ─────────────────────────────────────
424
+ // ── Error Handling (10 tests) ────────────────────────────────────
371
425
  {
372
426
  id: "error-unknown-method",
373
427
  name: "Returns JSON-RPC error for unknown method",
@@ -440,6 +494,24 @@ var TEST_DEFINITIONS = [
440
494
  description: "Calls tools/call with a nonexistent tool name and verifies the server returns an error response.",
441
495
  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
496
  },
497
+ {
498
+ id: "error-capability-gated",
499
+ name: "Rejects methods for undeclared capabilities",
500
+ category: "errors",
501
+ required: false,
502
+ specRef: "basic/lifecycle#capability-negotiation",
503
+ 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.",
504
+ recommendation: "Return a JSON-RPC error (e.g., -32601 Method not found) for methods associated with capabilities not declared in your initialize response."
505
+ },
506
+ {
507
+ id: "error-invalid-cursor",
508
+ name: "Handles invalid pagination cursor gracefully",
509
+ category: "errors",
510
+ required: false,
511
+ specRef: "basic",
512
+ 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.",
513
+ 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."
514
+ },
443
515
  // ── Schema Validation (6 tests) ──────────────────────────────────
444
516
  {
445
517
  id: "tools-schema",
@@ -456,8 +528,8 @@ var TEST_DEFINITIONS = [
456
528
  category: "schema",
457
529
  required: false,
458
530
  specRef: "server/tools#annotations",
459
- description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans; title should be a string.",
460
- recommendation: "If you include annotations on tools, ensure readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are booleans. Title must be a string."
531
+ description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans.",
532
+ 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
533
  },
462
534
  {
463
535
  id: "tools-title-field",
@@ -495,7 +567,7 @@ var TEST_DEFINITIONS = [
495
567
  description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
496
568
  recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
497
569
  },
498
- // ── Security: Auth & Transport (8 tests) ─────────────────────────
570
+ // ── Security: Auth & Transport (9 tests) ─────────────────────────
499
571
  {
500
572
  id: "security-auth-required",
501
573
  name: "Rejects unauthenticated requests",
@@ -543,12 +615,12 @@ var TEST_DEFINITIONS = [
543
615
  },
544
616
  {
545
617
  id: "security-oauth-metadata",
546
- name: "OAuth metadata endpoint exists",
618
+ name: "Protected Resource Metadata endpoint exists",
547
619
  category: "security",
548
620
  required: false,
549
621
  specRef: "basic/authorization",
550
- description: "Checks for a well-known OAuth authorization server metadata endpoint at /.well-known/oauth-authorization-server. If the server requires auth, it should advertise how to obtain tokens.",
551
- recommendation: "Publish an OAuth 2.0 Authorization Server Metadata document at /.well-known/oauth-authorization-server on your server's origin. Include issuer, token_endpoint, and supported grant types."
622
+ 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.",
623
+ 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
624
  },
553
625
  {
554
626
  id: "security-token-in-uri",
@@ -568,6 +640,15 @@ var TEST_DEFINITIONS = [
568
640
  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
641
  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
642
  },
643
+ {
644
+ id: "security-origin-validation",
645
+ name: "Validates Origin header on requests",
646
+ category: "security",
647
+ required: false,
648
+ specRef: "basic/transports#streamable-http",
649
+ 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.",
650
+ recommendation: "Validate the Origin header on all incoming requests. Reject requests from untrusted origins with HTTP 403. Maintain an allowlist of permitted origins."
651
+ },
571
652
  // ── Security: Input Validation (6 tests) ─────────────────────────
572
653
  {
573
654
  id: "security-command-injection",
@@ -753,7 +834,7 @@ function createIdCounter(start = 0) {
753
834
  }
754
835
  function parseSSEResponse(text) {
755
836
  const lines = text.split("\n");
756
- let lastJsonRpcResponse = null;
837
+ let firstJsonRpcResponse = null;
757
838
  let currentData = [];
758
839
  function flushEvent() {
759
840
  if (currentData.length === 0) return;
@@ -762,8 +843,8 @@ function parseSSEResponse(text) {
762
843
  if (!data.trim()) return;
763
844
  try {
764
845
  const parsed = JSON.parse(data);
765
- if (parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
766
- lastJsonRpcResponse = parsed;
846
+ if (!firstJsonRpcResponse && parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
847
+ firstJsonRpcResponse = parsed;
767
848
  }
768
849
  } catch {
769
850
  }
@@ -777,7 +858,7 @@ function parseSSEResponse(text) {
777
858
  }
778
859
  }
779
860
  flushEvent();
780
- return lastJsonRpcResponse;
861
+ return firstJsonRpcResponse;
781
862
  }
782
863
  async function mcpRequest(backendUrl, method, params, nextId, extraHeaders, timeout) {
783
864
  const id = nextId();
@@ -987,6 +1068,32 @@ async function runComplianceSuite(url, options = {}) {
987
1068
  return { passed: valid, details: `Content-Type: ${ct}` };
988
1069
  }
989
1070
  );
1071
+ await test(
1072
+ "transport-content-type-reject",
1073
+ "Rejects non-JSON request Content-Type",
1074
+ "transport",
1075
+ false,
1076
+ "basic/transports#streamable-http",
1077
+ async () => {
1078
+ const res = await request(backendUrl, {
1079
+ method: "POST",
1080
+ headers: { "Content-Type": "text/plain", Accept: "application/json, text/event-stream", ...userHeaders },
1081
+ body: JSON.stringify({ jsonrpc: "2.0", id: 99905, method: "ping" }),
1082
+ signal: AbortSignal.timeout(timeout)
1083
+ });
1084
+ await res.body.text();
1085
+ if (res.statusCode >= 400 && res.statusCode < 500) {
1086
+ return { passed: true, details: `HTTP ${res.statusCode} (incorrect Content-Type rejected)` };
1087
+ }
1088
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1089
+ return {
1090
+ passed: false,
1091
+ details: `HTTP ${res.statusCode} \u2014 server accepted text/plain Content-Type (should require application/json)`
1092
+ };
1093
+ }
1094
+ return { passed: false, details: `HTTP ${res.statusCode}` };
1095
+ }
1096
+ );
990
1097
  await test(
991
1098
  "transport-get",
992
1099
  "GET returns SSE stream or 405",
@@ -1001,7 +1108,8 @@ async function runComplianceSuite(url, options = {}) {
1001
1108
  signal: AbortSignal.timeout(timeout)
1002
1109
  });
1003
1110
  const body = await res.body.text();
1004
- const ct = (res.headers["content-type"] || "").toLowerCase();
1111
+ const rawCt = res.headers["content-type"];
1112
+ const ct = (Array.isArray(rawCt) ? rawCt[0] : rawCt || "").toLowerCase();
1005
1113
  if (res.statusCode === 405) {
1006
1114
  return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
1007
1115
  }
@@ -1109,6 +1217,50 @@ async function runComplianceSuite(url, options = {}) {
1109
1217
  return { passed: valid, details: `Version: ${version}` };
1110
1218
  }
1111
1219
  );
1220
+ await test(
1221
+ "lifecycle-version-negotiate",
1222
+ "Handles unknown protocol version",
1223
+ "lifecycle",
1224
+ false,
1225
+ "basic/lifecycle#version-negotiation",
1226
+ async () => {
1227
+ try {
1228
+ const futureRes = await mcpRequest(
1229
+ backendUrl,
1230
+ "initialize",
1231
+ {
1232
+ protocolVersion: "2099-01-01",
1233
+ capabilities: {},
1234
+ clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
1235
+ },
1236
+ createIdCounter(99960),
1237
+ userHeaders,
1238
+ timeout
1239
+ );
1240
+ const result = futureRes.body?.result;
1241
+ const error = futureRes.body?.error;
1242
+ if (error) {
1243
+ return {
1244
+ passed: true,
1245
+ details: `Server rejected unknown version with error: ${error.code} \u2014 ${error.message}`
1246
+ };
1247
+ }
1248
+ if (result?.protocolVersion) {
1249
+ const offered = result.protocolVersion;
1250
+ if (offered === "2099-01-01") {
1251
+ return {
1252
+ passed: false,
1253
+ details: 'Server accepted impossible future version "2099-01-01" \u2014 should offer a version it actually supports'
1254
+ };
1255
+ }
1256
+ return { passed: true, details: `Server negotiated down to ${offered} (correct)` };
1257
+ }
1258
+ return { passed: false, details: "No protocolVersion or error in response" };
1259
+ } catch {
1260
+ return { passed: true, details: "Connection rejected for unknown version (acceptable)" };
1261
+ }
1262
+ }
1263
+ );
1112
1264
  await test(
1113
1265
  "lifecycle-server-info",
1114
1266
  "Includes serverInfo",
@@ -1180,6 +1332,73 @@ async function runComplianceSuite(url, options = {}) {
1180
1332
  details: match ? `Request id=${res.requestId}, response id=${body.id} (match)` : `Request id=${res.requestId}, response id=${body.id} (MISMATCH)`
1181
1333
  };
1182
1334
  });
1335
+ await test("lifecycle-string-id", "Supports string request IDs", "lifecycle", false, "basic", async () => {
1336
+ const stringId = "compliance-test-string-id";
1337
+ const body = JSON.stringify({ jsonrpc: "2.0", id: stringId, method: "ping", params: {} });
1338
+ const res = await request(backendUrl, {
1339
+ method: "POST",
1340
+ headers: {
1341
+ "Content-Type": "application/json",
1342
+ Accept: "application/json, text/event-stream",
1343
+ ...buildHeaders()
1344
+ },
1345
+ body,
1346
+ signal: AbortSignal.timeout(timeout)
1347
+ });
1348
+ const text = await res.body.text();
1349
+ const rawCtStr = res.headers["content-type"];
1350
+ const ct = (Array.isArray(rawCtStr) ? rawCtStr[0] : rawCtStr || "").toLowerCase();
1351
+ let parsed;
1352
+ if (ct.includes("text/event-stream")) {
1353
+ parsed = parseSSEResponse(text);
1354
+ }
1355
+ if (!parsed) {
1356
+ try {
1357
+ parsed = JSON.parse(text);
1358
+ } catch {
1359
+ }
1360
+ }
1361
+ if (!parsed) return { passed: false, details: "Could not parse response" };
1362
+ if (parsed.id === stringId) {
1363
+ return { passed: true, details: `String id="${stringId}" echoed back correctly` };
1364
+ }
1365
+ if (parsed.id === void 0) {
1366
+ return { passed: false, details: "No id in response" };
1367
+ }
1368
+ return {
1369
+ passed: false,
1370
+ details: `String id="${stringId}" sent, got back id=${JSON.stringify(parsed.id)} (type: ${typeof parsed.id})`
1371
+ };
1372
+ });
1373
+ await test(
1374
+ "lifecycle-reinit-reject",
1375
+ "Rejects second initialize request",
1376
+ "lifecycle",
1377
+ false,
1378
+ "basic/lifecycle#initialization",
1379
+ async () => {
1380
+ try {
1381
+ const res = await rpc("initialize", {
1382
+ protocolVersion: SPEC_VERSION,
1383
+ capabilities: {},
1384
+ clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
1385
+ });
1386
+ const error = res.body?.error;
1387
+ if (error) {
1388
+ return { passed: true, details: `Re-initialization rejected with error: ${error.code} \u2014 ${error.message}` };
1389
+ }
1390
+ if (res.statusCode >= 400) {
1391
+ return { passed: true, details: `HTTP ${res.statusCode} (re-initialization rejected)` };
1392
+ }
1393
+ return {
1394
+ passed: false,
1395
+ details: `Server accepted second initialize (HTTP ${res.statusCode}) \u2014 should reject duplicate initialization`
1396
+ };
1397
+ } catch {
1398
+ return { passed: true, details: "Connection rejected (acceptable)" };
1399
+ }
1400
+ }
1401
+ );
1183
1402
  const hasLogging = !!serverInfo.capabilities.logging;
1184
1403
  await test(
1185
1404
  "lifecycle-logging",
@@ -1254,7 +1473,7 @@ async function runComplianceSuite(url, options = {}) {
1254
1473
  );
1255
1474
  await test(
1256
1475
  "lifecycle-progress",
1257
- "Accepts progress notifications",
1476
+ "Handles progress notifications gracefully",
1258
1477
  "lifecycle",
1259
1478
  false,
1260
1479
  "basic/utilities#progress",
@@ -1267,9 +1486,12 @@ async function runComplianceSuite(url, options = {}) {
1267
1486
  timeout
1268
1487
  );
1269
1488
  if (res.statusCode >= 200 && res.statusCode < 300) {
1270
- return { passed: true, details: `HTTP ${res.statusCode} (progress notification accepted)` };
1489
+ return { passed: true, details: `HTTP ${res.statusCode} (notification handled gracefully)` };
1271
1490
  }
1272
- return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept progress notifications` };
1491
+ return {
1492
+ passed: false,
1493
+ details: `HTTP ${res.statusCode} \u2014 server should accept unknown notifications without error`
1494
+ };
1273
1495
  }
1274
1496
  );
1275
1497
  await test(
@@ -1311,7 +1533,11 @@ async function runComplianceSuite(url, options = {}) {
1311
1533
  return { passed: true, details: "HTTP 202 Accepted (correct)" };
1312
1534
  }
1313
1535
  if (res.statusCode >= 200 && res.statusCode < 300) {
1314
- return { passed: true, details: `HTTP ${res.statusCode} (accepted, but 202 is preferred)` };
1536
+ warnings.push(`Notification returned HTTP ${res.statusCode} instead of spec-required 202 Accepted`);
1537
+ return {
1538
+ passed: false,
1539
+ details: `HTTP ${res.statusCode} \u2014 spec requires 202 Accepted for notifications (MUST)`
1540
+ };
1315
1541
  }
1316
1542
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected 202 Accepted for notifications` };
1317
1543
  }
@@ -1342,6 +1568,34 @@ async function runComplianceSuite(url, options = {}) {
1342
1568
  return { passed: false, details: `HTTP ${res.statusCode}` };
1343
1569
  }
1344
1570
  );
1571
+ await test(
1572
+ "transport-session-invalid",
1573
+ "Returns 404 for unknown session ID",
1574
+ "transport",
1575
+ false,
1576
+ "basic/transports#streamable-http",
1577
+ async () => {
1578
+ if (!sessionId) {
1579
+ return { passed: true, details: "Server did not issue session ID (test not applicable)" };
1580
+ }
1581
+ const fakeHeaders = {
1582
+ ...userHeaders,
1583
+ "mcp-session-id": "invalid-nonexistent-session-id"
1584
+ };
1585
+ if (negotiatedProtocolVersion) fakeHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1586
+ const res = await mcpRequest(backendUrl, "ping", void 0, createIdCounter(99915), fakeHeaders, timeout);
1587
+ if (res.statusCode === 404) {
1588
+ return { passed: true, details: "HTTP 404 for unknown session ID (correct per spec)" };
1589
+ }
1590
+ if (res.statusCode === 400) {
1591
+ return {
1592
+ passed: false,
1593
+ details: "HTTP 400 \u2014 spec requires 404 (Not Found) for unrecognized session IDs, not 400"
1594
+ };
1595
+ }
1596
+ return { passed: false, details: `HTTP ${res.statusCode} \u2014 spec requires 404 for unrecognized MCP-Session-Id` };
1597
+ }
1598
+ );
1345
1599
  await test(
1346
1600
  "transport-get-stream",
1347
1601
  "GET with session returns SSE or 405",
@@ -1358,7 +1612,8 @@ async function runComplianceSuite(url, options = {}) {
1358
1612
  signal: AbortSignal.timeout(Math.min(timeout, 3e3))
1359
1613
  });
1360
1614
  const body = await res.body.text();
1361
- const ct = (res.headers["content-type"] || "").toLowerCase();
1615
+ const rawCt2 = res.headers["content-type"];
1616
+ const ct = (Array.isArray(rawCt2) ? rawCt2[0] : rawCt2 || "").toLowerCase();
1362
1617
  if (res.statusCode === 405) {
1363
1618
  return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
1364
1619
  }
@@ -1397,7 +1652,8 @@ async function runComplianceSuite(url, options = {}) {
1397
1652
  signal: AbortSignal.timeout(timeout)
1398
1653
  }).then(async (res) => {
1399
1654
  const text = await res.body.text();
1400
- const ct = (res.headers["content-type"] || "").toLowerCase();
1655
+ const rawCtConcurrent = res.headers["content-type"];
1656
+ const ct = (Array.isArray(rawCtConcurrent) ? rawCtConcurrent[0] : rawCtConcurrent || "").toLowerCase();
1401
1657
  let body;
1402
1658
  if (ct.includes("text/event-stream")) {
1403
1659
  body = parseSSEResponse(text);
@@ -1424,6 +1680,42 @@ async function runComplianceSuite(url, options = {}) {
1424
1680
  return { passed: true, details: `${results.length} concurrent requests handled correctly` };
1425
1681
  }
1426
1682
  );
1683
+ await test(
1684
+ "transport-sse-event-field",
1685
+ "SSE responses include event: message",
1686
+ "transport",
1687
+ false,
1688
+ "basic/transports#streamable-http",
1689
+ async () => {
1690
+ const res = await request(backendUrl, {
1691
+ method: "POST",
1692
+ headers: {
1693
+ "Content-Type": "application/json",
1694
+ Accept: "text/event-stream",
1695
+ ...buildHeaders()
1696
+ },
1697
+ body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99940)(), method: "ping" }),
1698
+ signal: AbortSignal.timeout(timeout)
1699
+ });
1700
+ const text = await res.body.text();
1701
+ const rawCtSse = res.headers["content-type"];
1702
+ const ct = (Array.isArray(rawCtSse) ? rawCtSse[0] : rawCtSse || "").toLowerCase();
1703
+ if (!ct.includes("text/event-stream")) {
1704
+ return { passed: true, details: "Server responded with JSON (not SSE) \u2014 event field check not applicable" };
1705
+ }
1706
+ const hasEventMessage = /^event:\s*message\s*$/m.test(text);
1707
+ if (hasEventMessage) {
1708
+ return { passed: true, details: "SSE response includes required event: message field" };
1709
+ }
1710
+ if (text.includes("data:")) {
1711
+ return {
1712
+ passed: false,
1713
+ details: "SSE response has data: fields but missing required event: message field (spec: MUST include event: message)"
1714
+ };
1715
+ }
1716
+ return { passed: true, details: "SSE response empty or no data fields \u2014 check not applicable" };
1717
+ }
1718
+ );
1427
1719
  const hasTools = !!serverInfo.capabilities.tools;
1428
1720
  let cachedToolsList = null;
1429
1721
  await test(
@@ -1505,9 +1797,6 @@ async function runComplianceSuite(url, options = {}) {
1505
1797
  issues.push(`${tool.name}: annotations.${field} should be boolean, got ${typeof ann[field]}`);
1506
1798
  }
1507
1799
  }
1508
- if (ann.title !== void 0 && typeof ann.title !== "string") {
1509
- issues.push(`${tool.name}: annotations.title should be string`);
1510
- }
1511
1800
  }
1512
1801
  if (issues.length > 0) return { passed: false, details: issues.join("; ") };
1513
1802
  return {
@@ -2055,6 +2344,63 @@ async function runComplianceSuite(url, options = {}) {
2055
2344
  }
2056
2345
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
2057
2346
  });
2347
+ const undeclaredMethods = [
2348
+ { method: "tools/list", capability: "tools", declared: hasTools },
2349
+ { method: "resources/list", capability: "resources", declared: hasResources },
2350
+ { method: "prompts/list", capability: "prompts", declared: hasPrompts }
2351
+ ];
2352
+ const undeclared = undeclaredMethods.filter((m) => !m.declared);
2353
+ await test(
2354
+ "error-capability-gated",
2355
+ "Rejects methods for undeclared capabilities",
2356
+ "errors",
2357
+ false,
2358
+ "basic/lifecycle#capability-negotiation",
2359
+ async () => {
2360
+ if (undeclared.length === 0) {
2361
+ return {
2362
+ passed: true,
2363
+ details: "Server declares all capabilities (tools, resources, prompts) \u2014 no undeclared methods to test"
2364
+ };
2365
+ }
2366
+ const issues = [];
2367
+ for (const { method, capability } of undeclared) {
2368
+ const res = await rpc(method);
2369
+ const error = res.body?.error;
2370
+ if (!error && res.body?.result) {
2371
+ issues.push(`${method} returned success despite missing ${capability} capability`);
2372
+ }
2373
+ }
2374
+ if (issues.length > 0) return { passed: false, details: issues.join("; ") };
2375
+ return {
2376
+ passed: true,
2377
+ details: `Tested ${undeclared.length} undeclared method(s): ${undeclared.map((m) => m.method).join(", ")} \u2014 all returned errors`
2378
+ };
2379
+ }
2380
+ );
2381
+ const listMethodForCursor = hasTools ? "tools/list" : hasResources ? "resources/list" : hasPrompts ? "prompts/list" : null;
2382
+ await test(
2383
+ "error-invalid-cursor",
2384
+ "Handles invalid pagination cursor gracefully",
2385
+ "errors",
2386
+ false,
2387
+ "basic",
2388
+ async () => {
2389
+ if (!listMethodForCursor) {
2390
+ return { passed: true, details: "No list methods available to test (skipped)" };
2391
+ }
2392
+ const res = await rpc(listMethodForCursor, { cursor: "!!!invalid-garbage-cursor-$$$" });
2393
+ const error = res.body?.error;
2394
+ if (error) {
2395
+ return { passed: true, details: `Invalid cursor rejected with error: ${error.code} \u2014 ${error.message}` };
2396
+ }
2397
+ const result = res.body?.result;
2398
+ if (result) {
2399
+ return { passed: true, details: "Server returned results (likely ignored invalid cursor)" };
2400
+ }
2401
+ return { passed: false, details: "No error or result for invalid cursor" };
2402
+ }
2403
+ );
2058
2404
  const hasAuth = !!userHeaders.Authorization || !!userHeaders.authorization;
2059
2405
  await test(
2060
2406
  "security-auth-required",
@@ -2199,7 +2545,7 @@ async function runComplianceSuite(url, options = {}) {
2199
2545
  );
2200
2546
  await test(
2201
2547
  "security-oauth-metadata",
2202
- "OAuth metadata endpoint exists",
2548
+ "Protected Resource Metadata endpoint exists",
2203
2549
  "security",
2204
2550
  false,
2205
2551
  "basic/authorization",
@@ -2208,9 +2554,9 @@ async function runComplianceSuite(url, options = {}) {
2208
2554
  return { passed: true, details: "Skipped: server does not require auth" };
2209
2555
  }
2210
2556
  const parsedUrl = new URL(url);
2211
- const metadataUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
2557
+ const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
2212
2558
  try {
2213
- const res = await request(metadataUrl, {
2559
+ const res = await request(prmUrl, {
2214
2560
  method: "GET",
2215
2561
  headers: { Accept: "application/json" },
2216
2562
  signal: AbortSignal.timeout(Math.min(timeout, 5e3))
@@ -2219,20 +2565,51 @@ async function runComplianceSuite(url, options = {}) {
2219
2565
  if (res.statusCode === 200) {
2220
2566
  try {
2221
2567
  const meta = JSON.parse(text);
2222
- if (meta.issuer && meta.token_endpoint) {
2223
- return { passed: true, details: `OAuth metadata found: issuer=${meta.issuer}` };
2568
+ if (!meta.resource) {
2569
+ return { passed: false, details: "PRM response missing required 'resource' field" };
2570
+ }
2571
+ if (!Array.isArray(meta.authorization_servers) || meta.authorization_servers.length === 0) {
2572
+ return { passed: false, details: "PRM response missing 'authorization_servers' array" };
2224
2573
  }
2225
2574
  return {
2226
- passed: false,
2227
- details: "OAuth metadata response missing required fields (issuer, token_endpoint)"
2575
+ passed: true,
2576
+ details: `Protected Resource Metadata found: resource=${meta.resource}, ${meta.authorization_servers.length} auth server(s)`
2228
2577
  };
2229
2578
  } catch {
2230
- return { passed: false, details: "OAuth metadata endpoint returned non-JSON response" };
2579
+ return { passed: false, details: "PRM endpoint returned non-JSON response" };
2231
2580
  }
2232
2581
  }
2233
- return { passed: false, details: `OAuth metadata endpoint returned HTTP ${res.statusCode}` };
2582
+ const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
2583
+ try {
2584
+ const legacyRes = await request(legacyUrl, {
2585
+ method: "GET",
2586
+ headers: { Accept: "application/json" },
2587
+ signal: AbortSignal.timeout(Math.min(timeout, 5e3))
2588
+ });
2589
+ const legacyText = await legacyRes.body.text();
2590
+ if (legacyRes.statusCode === 200) {
2591
+ try {
2592
+ const legacyMeta = JSON.parse(legacyText);
2593
+ if (legacyMeta.issuer && legacyMeta.token_endpoint) {
2594
+ warnings.push(
2595
+ "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."
2596
+ );
2597
+ return {
2598
+ passed: true,
2599
+ details: `Legacy OAuth AS metadata found: issuer=${legacyMeta.issuer} (should migrate to PRM)`
2600
+ };
2601
+ }
2602
+ } catch {
2603
+ }
2604
+ }
2605
+ } catch {
2606
+ }
2607
+ return {
2608
+ passed: false,
2609
+ details: `PRM endpoint returned HTTP ${res.statusCode} and no legacy OAuth metadata found`
2610
+ };
2234
2611
  } catch {
2235
- return { passed: false, details: "OAuth metadata endpoint unreachable" };
2612
+ return { passed: false, details: "PRM endpoint unreachable" };
2236
2613
  }
2237
2614
  }
2238
2615
  );
@@ -2308,6 +2685,44 @@ async function runComplianceSuite(url, options = {}) {
2308
2685
  }
2309
2686
  }
2310
2687
  );
2688
+ await test(
2689
+ "security-origin-validation",
2690
+ "Validates Origin header on requests",
2691
+ "security",
2692
+ false,
2693
+ "basic/transports#streamable-http",
2694
+ async () => {
2695
+ try {
2696
+ const res = await request(backendUrl, {
2697
+ method: "POST",
2698
+ headers: {
2699
+ "Content-Type": "application/json",
2700
+ Accept: "application/json, text/event-stream",
2701
+ Origin: "https://evil-rebinding-attack.example.com",
2702
+ ...buildHeaders()
2703
+ },
2704
+ body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99970)(), method: "ping" }),
2705
+ signal: AbortSignal.timeout(timeout)
2706
+ });
2707
+ await res.body.text();
2708
+ if (res.statusCode === 403 || res.statusCode === 401) {
2709
+ return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
2710
+ }
2711
+ if (res.statusCode >= 200 && res.statusCode < 300) {
2712
+ return {
2713
+ passed: false,
2714
+ details: `HTTP ${res.statusCode} \u2014 server accepted request with untrusted Origin header (spec: MUST validate Origin for DNS rebinding protection)`
2715
+ };
2716
+ }
2717
+ if (res.statusCode >= 400) {
2718
+ return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
2719
+ }
2720
+ return { passed: false, details: `HTTP ${res.statusCode}` };
2721
+ } catch {
2722
+ return { passed: true, details: "Connection rejected (acceptable)" };
2723
+ }
2724
+ }
2725
+ );
2311
2726
  async function runInjectionTest(toolName, paramName, payloads, detectPattern, label) {
2312
2727
  const issues = [];
2313
2728
  for (const payload of payloads) {