@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 +10 -7
- package/dist/{chunk-SELO4TOW.js → chunk-4RNSPRSQ.js} +150 -5
- package/dist/index.js +153 -7
- package/dist/mcp/server.js +2 -2
- package/dist/runner.d.ts +3 -1
- package/dist/runner.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/mcp-compliance/stargazers)
|
|
6
6
|
[](https://github.com/YawLabs/mcp-compliance/actions/workflows/ci.yml)
|
|
7
7
|
|
|
8
|
-
**Test any MCP server for spec compliance.**
|
|
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
|
-
- **
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
|
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,
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
SPEC_BASE,
|
|
3
3
|
TEST_DEFINITIONS,
|
|
4
4
|
runComplianceSuite
|
|
5
|
-
} from "../chunk-
|
|
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
|
|
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
|
|
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
package/package.json
CHANGED