@yawlabs/mcp-compliance 0.7.0 → 0.8.1

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/mcp-compliance)](https://github.com/YawLabs/mcp-compliance/stargazers)
6
6
  [![CI](https://github.com/YawLabs/mcp-compliance/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/mcp-compliance/actions/workflows/ci.yml)
7
7
 
8
- **Test any MCP server for spec compliance.** 78-test suite covering transport, lifecycle, tools, resources, prompts, error handling, schema validation, and security against the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25). CLI, MCP server, and programmatic API.
8
+ **Test any MCP server for spec compliance.** 81-test suite covering transport, lifecycle, tools, resources, prompts, error handling, schema validation, and security against the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25). CLI, MCP server, and programmatic API.
9
9
 
10
10
  Built and maintained by [Yaw Labs](https://yaw.sh).
11
11
 
@@ -15,7 +15,7 @@ MCP servers are multiplying fast — but most ship without compliance testing. B
15
15
 
16
16
  This tool solves that:
17
17
 
18
- - **78 tests across 8 categories** — transport, lifecycle, tools, resources, prompts, error handling, schema validation, and security. No gaps.
18
+ - **81 tests across 8 categories** — transport, lifecycle, tools, resources, prompts, error handling, schema validation, and security. No gaps.
19
19
  - **Capability-driven** — tests adapt to what the server declares. If it says it supports tools, tool tests become required. No false failures for features the server doesn't claim.
20
20
  - **Graded scoring** — A-F letter grade with a weighted score (required tests 70%, optional 30%). One number to communicate compliance.
21
21
  - **CI-ready** — `--strict` mode exits with code 1 on required test failures. Drop it into any pipeline.
@@ -100,7 +100,7 @@ mcp-compliance badge https://my-server.com/mcp
100
100
 
101
101
  Outputs the markdown embed for a compliance badge hosted at [mcp.hosting](https://mcp.hosting).
102
102
 
103
- ## What the 78 tests check
103
+ ## What the 81 tests check
104
104
 
105
105
  <details>
106
106
  <summary><strong>Transport (13 tests)</strong></summary>
@@ -122,7 +122,7 @@ Outputs the markdown embed for a compliance badge hosted at [mcp.hosting](https:
122
122
  </details>
123
123
 
124
124
  <details>
125
- <summary><strong>Lifecycle (15 tests)</strong></summary>
125
+ <summary><strong>Lifecycle (17 tests)</strong></summary>
126
126
 
127
127
  - **lifecycle-init** — Initialize handshake succeeds (required)
128
128
  - **lifecycle-proto-version** — Returns valid YYYY-MM-DD protocol version (required)
@@ -139,6 +139,8 @@ Outputs the markdown embed for a compliance badge hosted at [mcp.hosting](https:
139
139
  - **lifecycle-completions** — completion/complete accepted (required if completions capability declared)
140
140
  - **lifecycle-cancellation** — Handles cancellation notifications
141
141
  - **lifecycle-progress** — Handles progress notifications gracefully
142
+ - **lifecycle-list-changed** — Accepts listChanged notifications for declared capabilities
143
+ - **lifecycle-progress-token** — Supports progress tokens in requests via SSE
142
144
 
143
145
  </details>
144
146
 
@@ -201,9 +203,10 @@ Outputs the markdown embed for a compliance badge hosted at [mcp.hosting](https:
201
203
  </details>
202
204
 
203
205
  <details>
204
- <summary><strong>Security (22 tests)</strong></summary>
206
+ <summary><strong>Security (23 tests)</strong></summary>
205
207
 
206
208
  - **security-auth-required** — Rejects unauthenticated requests
209
+ - **security-www-authenticate** — 401 responses include WWW-Authenticate header
207
210
  - **security-auth-malformed** — Rejects malformed auth credentials
208
211
  - **security-tls-required** — Enforces HTTPS/TLS
209
212
  - **security-session-entropy** — Session IDs are high-entropy
@@ -318,7 +321,7 @@ Restart your MCP client and approve the server when prompted.
318
321
 
319
322
  ### Tools
320
323
 
321
- - **mcp_compliance_test** — Run the full 78-test suite against a URL. Supports auth, custom headers, timeout, retries, and category/test filtering. Returns grade, score, and detailed results.
324
+ - **mcp_compliance_test** — Run the full 81-test suite against a URL. Supports auth, custom headers, timeout, retries, and category/test filtering. Returns grade, score, and detailed results.
322
325
  - **mcp_compliance_badge** — Get the badge markdown/HTML for a server. Supports auth and custom headers.
323
326
  - **mcp_compliance_explain** — Explain what a specific test ID checks and why it matters.
324
327
 
@@ -345,7 +348,7 @@ const report2 = await runComplianceSuite('https://my-server.com/mcp', {
345
348
 
346
349
  The compliance testing methodology is published as an open specification:
347
350
 
348
- - **[MCP Compliance Testing Specification](./MCP_COMPLIANCE_SPEC.md)** — test execution model, scoring algorithm, all 78 test rules with pass/fail criteria (CC BY 4.0)
351
+ - **[MCP Compliance Testing Specification](./MCP_COMPLIANCE_SPEC.md)** — test execution model, scoring algorithm, all 81 test rules with pass/fail criteria (CC BY 4.0)
349
352
  - **[Machine-readable rule catalog](./mcp-compliance-rules.json)** — JSON Schema-compliant catalog for programmatic consumption
350
353
 
351
354
  These are complementary to (not competing with) the [official MCP specification](https://modelcontextprotocol.io/specification/2025-11-25). The MCP spec defines what servers must do; this spec defines how to verify compliance.
@@ -174,7 +174,7 @@ var TEST_DEFINITIONS = [
174
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
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
176
  },
177
- // ── Lifecycle (15 tests) ─────────────────────────────────────────
177
+ // ── Lifecycle (17 tests) ─────────────────────────────────────────
178
178
  {
179
179
  id: "lifecycle-init",
180
180
  name: "Initialize handshake",
@@ -310,6 +310,24 @@ var TEST_DEFINITIONS = [
310
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
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
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."
330
+ },
313
331
  // ── Tools (4 tests) ──────────────────────────────────────────────
314
332
  {
315
333
  id: "tools-list",
@@ -567,7 +585,7 @@ var TEST_DEFINITIONS = [
567
585
  description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
568
586
  recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
569
587
  },
570
- // ── Security: Auth & Transport (9 tests) ─────────────────────────
588
+ // ── Security: Auth & Transport (10 tests) ────────────────────────
571
589
  {
572
590
  id: "security-auth-required",
573
591
  name: "Rejects unauthenticated requests",
@@ -577,6 +595,15 @@ var TEST_DEFINITIONS = [
577
595
  description: "Sends a request without an Authorization header and verifies the server returns HTTP 401. Servers exposed over the network should require authentication.",
578
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."
579
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
+ },
580
607
  {
581
608
  id: "security-auth-malformed",
582
609
  name: "Rejects malformed auth credentials",
@@ -942,7 +969,7 @@ async function runComplianceSuite(url, options = {}) {
942
969
  ...options.headers
943
970
  },
944
971
  body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
945
- signal: AbortSignal.timeout(Math.min(options.timeout || 15e3, 1e4))
972
+ signal: AbortSignal.timeout(options.preflightTimeout ?? Math.min(options.timeout || 15e3, 1e4))
946
973
  });
947
974
  await preflight.body.text();
948
975
  } catch {
@@ -1494,6 +1521,90 @@ async function runComplianceSuite(url, options = {}) {
1494
1521
  };
1495
1522
  }
1496
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)" };
1605
+ }
1606
+ }
1607
+ );
1497
1608
  await test(
1498
1609
  "transport-content-type-init",
1499
1610
  "Initialize response has valid content type",
@@ -1961,7 +2072,8 @@ async function runComplianceSuite(url, options = {}) {
1961
2072
  );
1962
2073
  }
1963
2074
  const hasResources = !!serverInfo.capabilities.resources;
1964
- const hasSubscribe = !!serverInfo.capabilities.resources?.subscribe;
2075
+ const resourcesCap = serverInfo.capabilities.resources;
2076
+ const hasSubscribe = !!(typeof resourcesCap === "object" && resourcesCap !== null && "subscribe" in resourcesCap && resourcesCap.subscribe);
1965
2077
  if (hasResources) {
1966
2078
  let cachedResourcesList = null;
1967
2079
  await test(
@@ -2428,6 +2540,39 @@ async function runComplianceSuite(url, options = {}) {
2428
2540
  }
2429
2541
  }
2430
2542
  );
2543
+ await test(
2544
+ "security-www-authenticate",
2545
+ "401 responses include WWW-Authenticate header",
2546
+ "security",
2547
+ false,
2548
+ "basic/authorization",
2549
+ async () => {
2550
+ if (!hasAuth) {
2551
+ return { passed: true, details: "Skipped: server does not require auth" };
2552
+ }
2553
+ const noAuthHeaders = {};
2554
+ if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
2555
+ try {
2556
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
2557
+ if (res.statusCode === 401) {
2558
+ const wwwAuth = res.headers["www-authenticate"];
2559
+ if (wwwAuth) {
2560
+ return { passed: true, details: `WWW-Authenticate: ${wwwAuth}` };
2561
+ }
2562
+ return {
2563
+ passed: false,
2564
+ details: "HTTP 401 but missing WWW-Authenticate header (spec: SHOULD include to indicate required auth scheme)"
2565
+ };
2566
+ }
2567
+ if (res.statusCode === 403) {
2568
+ return { passed: true, details: "HTTP 403 (WWW-Authenticate not applicable for 403)" };
2569
+ }
2570
+ return { passed: true, details: `HTTP ${res.statusCode} \u2014 not a 401 response` };
2571
+ } catch {
2572
+ return { passed: true, details: "Connection rejected (acceptable)" };
2573
+ }
2574
+ }
2575
+ );
2431
2576
  await test(
2432
2577
  "security-auth-malformed",
2433
2578
  "Rejects malformed auth credentials",
@@ -3209,7 +3354,7 @@ async function runComplianceSuite(url, options = {}) {
3209
3354
  return { passed: false, details: `HTTP ${res.statusCode}` };
3210
3355
  }
3211
3356
  );
3212
- const MAX_WARNINGS = 50;
3357
+ const MAX_WARNINGS = 100;
3213
3358
  if (warnings.length > MAX_WARNINGS) {
3214
3359
  const truncated = warnings.length - MAX_WARNINGS;
3215
3360
  warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
package/dist/index.js CHANGED
@@ -189,7 +189,7 @@ var TEST_DEFINITIONS = [
189
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
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
191
  },
192
- // ── Lifecycle (15 tests) ─────────────────────────────────────────
192
+ // ── Lifecycle (17 tests) ─────────────────────────────────────────
193
193
  {
194
194
  id: "lifecycle-init",
195
195
  name: "Initialize handshake",
@@ -325,6 +325,24 @@ var TEST_DEFINITIONS = [
325
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
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."
327
327
  },
328
+ {
329
+ id: "lifecycle-list-changed",
330
+ name: "Accepts listChanged notifications",
331
+ category: "lifecycle",
332
+ required: false,
333
+ specRef: "basic/lifecycle#capability-negotiation",
334
+ description: "Sends notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed for declared capabilities and verifies the server accepts them.",
335
+ 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."
336
+ },
337
+ {
338
+ id: "lifecycle-progress-token",
339
+ name: "Supports progress tokens in requests",
340
+ category: "lifecycle",
341
+ required: false,
342
+ specRef: "basic/utilities#progress",
343
+ 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.",
344
+ recommendation: "When a request includes _meta.progressToken, send notifications/progress events via SSE to report progress. Include progressToken, progress (current), and optionally total fields."
345
+ },
328
346
  // ── Tools (4 tests) ──────────────────────────────────────────────
329
347
  {
330
348
  id: "tools-list",
@@ -582,7 +600,7 @@ var TEST_DEFINITIONS = [
582
600
  description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
583
601
  recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
584
602
  },
585
- // ── Security: Auth & Transport (9 tests) ─────────────────────────
603
+ // ── Security: Auth & Transport (10 tests) ────────────────────────
586
604
  {
587
605
  id: "security-auth-required",
588
606
  name: "Rejects unauthenticated requests",
@@ -592,6 +610,15 @@ var TEST_DEFINITIONS = [
592
610
  description: "Sends a request without an Authorization header and verifies the server returns HTTP 401. Servers exposed over the network should require authentication.",
593
611
  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."
594
612
  },
613
+ {
614
+ id: "security-www-authenticate",
615
+ name: "401 responses include WWW-Authenticate header",
616
+ category: "security",
617
+ required: false,
618
+ specRef: "basic/authorization",
619
+ 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.",
620
+ recommendation: `Include a WWW-Authenticate header in 401 responses to indicate the required auth scheme (e.g., 'WWW-Authenticate: Bearer realm="mcp"').`
621
+ },
595
622
  {
596
623
  id: "security-auth-malformed",
597
624
  name: "Rejects malformed auth credentials",
@@ -957,7 +984,7 @@ async function runComplianceSuite(url, options = {}) {
957
984
  ...options.headers
958
985
  },
959
986
  body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
960
- signal: AbortSignal.timeout(Math.min(options.timeout || 15e3, 1e4))
987
+ signal: AbortSignal.timeout(options.preflightTimeout ?? Math.min(options.timeout || 15e3, 1e4))
961
988
  });
962
989
  await preflight.body.text();
963
990
  } catch {
@@ -1509,6 +1536,90 @@ async function runComplianceSuite(url, options = {}) {
1509
1536
  };
1510
1537
  }
1511
1538
  );
1539
+ await test(
1540
+ "lifecycle-list-changed",
1541
+ "Accepts listChanged notifications",
1542
+ "lifecycle",
1543
+ false,
1544
+ "basic/lifecycle#capability-negotiation",
1545
+ async () => {
1546
+ const notifications = [
1547
+ { method: "notifications/tools/list_changed", gate: hasTools },
1548
+ { method: "notifications/resources/list_changed", gate: hasResources },
1549
+ { method: "notifications/prompts/list_changed", gate: hasPrompts }
1550
+ ];
1551
+ const applicable = notifications.filter((n) => n.gate);
1552
+ if (applicable.length === 0) {
1553
+ return { passed: true, details: "No capabilities declared \u2014 listChanged notifications not applicable" };
1554
+ }
1555
+ const issues = [];
1556
+ for (const { method } of applicable) {
1557
+ try {
1558
+ const res = await mcpNotification(backendUrl, method, void 0, buildHeaders(), timeout);
1559
+ if (res.statusCode < 200 || res.statusCode >= 300) {
1560
+ issues.push(`${method}: HTTP ${res.statusCode}`);
1561
+ }
1562
+ } catch (err) {
1563
+ issues.push(`${method}: ${err instanceof Error ? err.message : "error"}`);
1564
+ }
1565
+ }
1566
+ if (issues.length > 0) return { passed: false, details: issues.join("; ") };
1567
+ return {
1568
+ passed: true,
1569
+ details: `${applicable.length} listChanged notification(s) accepted: ${applicable.map((n) => n.method).join(", ")}`
1570
+ };
1571
+ }
1572
+ );
1573
+ await test(
1574
+ "lifecycle-progress-token",
1575
+ "Supports progress tokens in requests",
1576
+ "lifecycle",
1577
+ false,
1578
+ "basic/utilities#progress",
1579
+ async () => {
1580
+ if (!hasTools || toolNames.length === 0) {
1581
+ return { passed: true, details: "No tools available for progress token test (skipped)" };
1582
+ }
1583
+ const progressToken = "compliance-progress-test";
1584
+ const reqBody = JSON.stringify({
1585
+ jsonrpc: "2.0",
1586
+ id: nextId(),
1587
+ method: "tools/call",
1588
+ params: {
1589
+ name: toolNames[0],
1590
+ arguments: {},
1591
+ _meta: { progressToken }
1592
+ }
1593
+ });
1594
+ try {
1595
+ const res = await request(backendUrl, {
1596
+ method: "POST",
1597
+ headers: {
1598
+ "Content-Type": "application/json",
1599
+ Accept: "text/event-stream",
1600
+ ...buildHeaders()
1601
+ },
1602
+ body: reqBody,
1603
+ signal: AbortSignal.timeout(timeout)
1604
+ });
1605
+ const text = await res.body.text();
1606
+ const rawCtProgress = res.headers["content-type"];
1607
+ const ct = (Array.isArray(rawCtProgress) ? rawCtProgress[0] : rawCtProgress || "").toLowerCase();
1608
+ if (ct.includes("text/event-stream") && text.includes("notifications/progress")) {
1609
+ return { passed: true, details: "Server sent progress notifications via SSE with progressToken" };
1610
+ }
1611
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1612
+ return {
1613
+ passed: true,
1614
+ details: "Server accepted request with progressToken (no progress events observed \u2014 optional)"
1615
+ };
1616
+ }
1617
+ return { passed: true, details: `HTTP ${res.statusCode} \u2014 request with progressToken accepted` };
1618
+ } catch {
1619
+ return { passed: true, details: "Request with progressToken handled (no progress events observed \u2014 optional)" };
1620
+ }
1621
+ }
1622
+ );
1512
1623
  await test(
1513
1624
  "transport-content-type-init",
1514
1625
  "Initialize response has valid content type",
@@ -1976,7 +2087,8 @@ async function runComplianceSuite(url, options = {}) {
1976
2087
  );
1977
2088
  }
1978
2089
  const hasResources = !!serverInfo.capabilities.resources;
1979
- const hasSubscribe = !!serverInfo.capabilities.resources?.subscribe;
2090
+ const resourcesCap = serverInfo.capabilities.resources;
2091
+ const hasSubscribe = !!(typeof resourcesCap === "object" && resourcesCap !== null && "subscribe" in resourcesCap && resourcesCap.subscribe);
1980
2092
  if (hasResources) {
1981
2093
  let cachedResourcesList = null;
1982
2094
  await test(
@@ -2443,6 +2555,39 @@ async function runComplianceSuite(url, options = {}) {
2443
2555
  }
2444
2556
  }
2445
2557
  );
2558
+ await test(
2559
+ "security-www-authenticate",
2560
+ "401 responses include WWW-Authenticate header",
2561
+ "security",
2562
+ false,
2563
+ "basic/authorization",
2564
+ async () => {
2565
+ if (!hasAuth) {
2566
+ return { passed: true, details: "Skipped: server does not require auth" };
2567
+ }
2568
+ const noAuthHeaders = {};
2569
+ if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
2570
+ try {
2571
+ const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
2572
+ if (res.statusCode === 401) {
2573
+ const wwwAuth = res.headers["www-authenticate"];
2574
+ if (wwwAuth) {
2575
+ return { passed: true, details: `WWW-Authenticate: ${wwwAuth}` };
2576
+ }
2577
+ return {
2578
+ passed: false,
2579
+ details: "HTTP 401 but missing WWW-Authenticate header (spec: SHOULD include to indicate required auth scheme)"
2580
+ };
2581
+ }
2582
+ if (res.statusCode === 403) {
2583
+ return { passed: true, details: "HTTP 403 (WWW-Authenticate not applicable for 403)" };
2584
+ }
2585
+ return { passed: true, details: `HTTP ${res.statusCode} \u2014 not a 401 response` };
2586
+ } catch {
2587
+ return { passed: true, details: "Connection rejected (acceptable)" };
2588
+ }
2589
+ }
2590
+ );
2446
2591
  await test(
2447
2592
  "security-auth-malformed",
2448
2593
  "Rejects malformed auth credentials",
@@ -3224,7 +3369,7 @@ async function runComplianceSuite(url, options = {}) {
3224
3369
  return { passed: false, details: `HTTP ${res.statusCode}` };
3225
3370
  }
3226
3371
  );
3227
- const MAX_WARNINGS = 50;
3372
+ const MAX_WARNINGS = 100;
3228
3373
  if (warnings.length > MAX_WARNINGS) {
3229
3374
  const truncated = warnings.length - MAX_WARNINGS;
3230
3375
  warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
@@ -3258,7 +3403,7 @@ async function runComplianceSuite(url, options = {}) {
3258
3403
  function registerTools(server) {
3259
3404
  server.tool(
3260
3405
  "mcp_compliance_test",
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.",
3406
+ "Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all 81 tests covering transport, lifecycle, tools, resources, prompts, errors, schema validation, and security.",
3262
3407
  {
3263
3408
  url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
3264
3409
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
@@ -3682,7 +3827,7 @@ var program = new Command();
3682
3827
  program.name("mcp-compliance").description("Test MCP servers for spec compliance").version(version2);
3683
3828
  program.command("test").description("Run the full compliance test suite against an MCP server").argument("<url>", "MCP server URL to test").addOption(
3684
3829
  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(
3830
+ ).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("--preflight-timeout <ms>", "Preflight connectivity check timeout in milliseconds").option("--retries <n>", "Number of retries for failed tests", "0").option(
3686
3831
  "--only <items>",
3687
3832
  'Only run matching categories or test IDs, comma-separated (e.g., "transport,lifecycle" or "transport-post,lifecycle-init")',
3688
3833
  parseList
@@ -3703,6 +3848,7 @@ Testing ${url}...
3703
3848
  const report = await runComplianceSuite(url, {
3704
3849
  headers,
3705
3850
  timeout: parsePositiveInt(opts.timeout, "--timeout", 1),
3851
+ preflightTimeout: opts.preflightTimeout ? parsePositiveInt(opts.preflightTimeout, "--preflight-timeout", 1) : void 0,
3706
3852
  retries: parsePositiveInt(opts.retries, "--retries"),
3707
3853
  only: opts.only,
3708
3854
  skip: opts.skip,
@@ -2,7 +2,7 @@ import {
2
2
  SPEC_BASE,
3
3
  TEST_DEFINITIONS,
4
4
  runComplianceSuite
5
- } from "../chunk-SELO4TOW.js";
5
+ } from "../chunk-4RNSPRSQ.js";
6
6
 
7
7
  // src/mcp/server.ts
8
8
  import { createRequire } from "module";
@@ -14,7 +14,7 @@ import { z } from "zod";
14
14
  function registerTools(server) {
15
15
  server.tool(
16
16
  "mcp_compliance_test",
17
- "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.",
17
+ "Run the full MCP compliance test suite against a server URL. Returns grade (A-F), score, and detailed results for all 81 tests covering transport, lifecycle, tools, resources, prompts, errors, schema validation, and security.",
18
18
  {
19
19
  url: z.string().url().describe("The MCP server URL to test (must be HTTP or HTTPS)"),
20
20
  auth: z.string().optional().describe('Authorization header value (e.g., "Bearer tok123")'),
package/dist/runner.d.ts CHANGED
@@ -60,7 +60,7 @@ interface TestDefinition {
60
60
  description: string;
61
61
  recommendation: string;
62
62
  }
63
- /** All 78 test IDs with descriptions for the explain command */
63
+ /** All 81 test IDs with descriptions for the explain command */
64
64
  declare const TEST_DEFINITIONS: TestDefinition[];
65
65
 
66
66
  declare function computeGrade(score: number): Grade;
@@ -115,6 +115,8 @@ interface RunOptions {
115
115
  only?: string[];
116
116
  /** Skip tests matching these category names or test IDs */
117
117
  skip?: string[];
118
+ /** Preflight connectivity check timeout in milliseconds (default: min(timeout, 10000)) */
119
+ preflightTimeout?: number;
118
120
  }
119
121
  /**
120
122
  * Run the full MCP compliance test suite against a URL.
package/dist/runner.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  generateBadge,
8
8
  parseSSEResponse,
9
9
  runComplianceSuite
10
- } from "./chunk-SELO4TOW.js";
10
+ } from "./chunk-4RNSPRSQ.js";
11
11
  export {
12
12
  SPEC_BASE,
13
13
  SPEC_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/mcp-compliance",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "CLI tool and MCP server that tests MCP servers for spec compliance",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",