@yawlabs/mcp-compliance 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -7
- package/dist/{chunk-KNOSZ3TD.js → chunk-QLFYT4I7.js} +1277 -62
- package/dist/index.js +1342 -105
- package/dist/mcp/server.js +8 -5
- package/dist/runner.d.ts +13 -3
- package/dist/runner.js +7 -1
- package/package.json +1 -1
|
@@ -3,16 +3,14 @@ import { createRequire } from "module";
|
|
|
3
3
|
import { request } from "undici";
|
|
4
4
|
|
|
5
5
|
// src/badge.ts
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
function urlHash(url) {
|
|
8
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 12);
|
|
9
|
+
}
|
|
6
10
|
function generateBadge(url) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} catch {
|
|
11
|
-
parsed = new URL("https://unknown");
|
|
12
|
-
}
|
|
13
|
-
const encoded = encodeURIComponent(parsed.href);
|
|
14
|
-
const imageUrl = `https://mcp.hosting/api/compliance/${encoded}/badge`;
|
|
15
|
-
const reportUrl = `https://mcp.hosting/compliance/${encoded}`;
|
|
11
|
+
const hash = urlHash(url);
|
|
12
|
+
const imageUrl = `https://mcp.hosting/api/compliance/${hash}/badge`;
|
|
13
|
+
const reportUrl = `https://mcp.hosting/compliance/${hash}`;
|
|
16
14
|
return {
|
|
17
15
|
imageUrl,
|
|
18
16
|
reportUrl,
|
|
@@ -58,7 +56,7 @@ function computeScore(tests) {
|
|
|
58
56
|
|
|
59
57
|
// src/types.ts
|
|
60
58
|
var TEST_DEFINITIONS = [
|
|
61
|
-
// ── Transport (
|
|
59
|
+
// ── Transport (10 tests) ─────────────────────────────────────────
|
|
62
60
|
{
|
|
63
61
|
id: "transport-post",
|
|
64
62
|
name: "HTTP POST accepted",
|
|
@@ -122,7 +120,34 @@ var TEST_DEFINITIONS = [
|
|
|
122
120
|
description: "Sends a JSON-RPC batch request (array of messages) and verifies the server rejects it with an error. MCP does not support JSON-RPC batch requests.",
|
|
123
121
|
recommendation: "Check if the parsed JSON body is an array. If so, return a JSON-RPC error or HTTP 400. Do not process batch requests \u2014 MCP explicitly forbids them."
|
|
124
122
|
},
|
|
125
|
-
|
|
123
|
+
{
|
|
124
|
+
id: "transport-content-type-init",
|
|
125
|
+
name: "Initialize response has valid content type",
|
|
126
|
+
category: "transport",
|
|
127
|
+
required: false,
|
|
128
|
+
specRef: "basic/transports#streamable-http",
|
|
129
|
+
description: "Validates that the initialize response uses application/json or text/event-stream content type. Some servers return other types for the handshake.",
|
|
130
|
+
recommendation: 'Ensure the initialize response uses Content-Type "application/json" or "text/event-stream". Do not return text/html or other types for JSON-RPC responses.'
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "transport-get-stream",
|
|
134
|
+
name: "GET with session returns SSE or 405",
|
|
135
|
+
category: "transport",
|
|
136
|
+
required: false,
|
|
137
|
+
specRef: "basic/transports#streamable-http",
|
|
138
|
+
description: "Tests the GET endpoint with an active session ID for server-initiated messages. After initialization, the server should either return an SSE stream or 405.",
|
|
139
|
+
recommendation: "If your server supports server-initiated messages, return text/event-stream on GET with a valid session ID. Otherwise, return 405 Method Not Allowed."
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "transport-concurrent",
|
|
143
|
+
name: "Handles concurrent requests",
|
|
144
|
+
category: "transport",
|
|
145
|
+
required: false,
|
|
146
|
+
specRef: "basic/transports#streamable-http",
|
|
147
|
+
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
|
+
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
|
+
},
|
|
150
|
+
// ── Lifecycle (12 tests) ─────────────────────────────────────────
|
|
126
151
|
{
|
|
127
152
|
id: "lifecycle-init",
|
|
128
153
|
name: "Initialize handshake",
|
|
@@ -213,6 +238,24 @@ var TEST_DEFINITIONS = [
|
|
|
213
238
|
description: "If the server declares completions capability, tests that the completion/complete method is accepted.",
|
|
214
239
|
recommendation: 'If you declare completions in capabilities, implement the "completion/complete" handler. Return a completion object with a values array, even if empty.'
|
|
215
240
|
},
|
|
241
|
+
{
|
|
242
|
+
id: "lifecycle-cancellation",
|
|
243
|
+
name: "Handles cancellation notifications",
|
|
244
|
+
category: "lifecycle",
|
|
245
|
+
required: false,
|
|
246
|
+
specRef: "basic/utilities#cancellation",
|
|
247
|
+
description: "Tests that the server accepts notifications/cancelled without error. Servers should gracefully handle cancellation of unknown or completed requests.",
|
|
248
|
+
recommendation: "Accept notifications/cancelled and stop any in-progress work for the referenced requestId. If the request is unknown or already complete, silently ignore the cancellation."
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "lifecycle-progress",
|
|
252
|
+
name: "Accepts progress notifications",
|
|
253
|
+
category: "lifecycle",
|
|
254
|
+
required: false,
|
|
255
|
+
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."
|
|
258
|
+
},
|
|
216
259
|
// ── Tools (4 tests) ──────────────────────────────────────────────
|
|
217
260
|
{
|
|
218
261
|
id: "tools-list",
|
|
@@ -451,15 +494,259 @@ var TEST_DEFINITIONS = [
|
|
|
451
494
|
specRef: "server/resources#data-types",
|
|
452
495
|
description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
|
|
453
496
|
recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
|
|
497
|
+
},
|
|
498
|
+
// ── Security: Auth & Transport (8 tests) ─────────────────────────
|
|
499
|
+
{
|
|
500
|
+
id: "security-auth-required",
|
|
501
|
+
name: "Rejects unauthenticated requests",
|
|
502
|
+
category: "security",
|
|
503
|
+
required: false,
|
|
504
|
+
specRef: "basic/authorization",
|
|
505
|
+
description: "Sends a request without an Authorization header and verifies the server returns HTTP 401. Servers exposed over the network should require authentication.",
|
|
506
|
+
recommendation: "Implement authentication on your MCP endpoint. Return HTTP 401 Unauthorized for requests without valid credentials. Use OAuth 2.1 or Bearer tokens as recommended by the MCP spec."
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
id: "security-auth-malformed",
|
|
510
|
+
name: "Rejects malformed auth credentials",
|
|
511
|
+
category: "security",
|
|
512
|
+
required: false,
|
|
513
|
+
specRef: "basic/authorization",
|
|
514
|
+
description: "Sends a request with a malformed Authorization header (garbage value) and verifies the server returns HTTP 401 or 403. Servers must validate auth tokens, not just check for presence.",
|
|
515
|
+
recommendation: "Validate the format and signature of Authorization header values. Reject malformed or invalid tokens with HTTP 401. Do not treat any non-empty Authorization header as valid."
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
id: "security-tls-required",
|
|
519
|
+
name: "Enforces HTTPS/TLS",
|
|
520
|
+
category: "security",
|
|
521
|
+
required: false,
|
|
522
|
+
specRef: "basic/authorization",
|
|
523
|
+
description: "If the server URL uses HTTPS, attempts an HTTP (plaintext) connection and verifies it is rejected or redirected. Production MCP servers should not accept plaintext connections.",
|
|
524
|
+
recommendation: "Configure your server to reject HTTP connections or redirect to HTTPS. Use TLS 1.2 or higher. The MCP spec requires HTTPS for production deployments."
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
id: "security-session-entropy",
|
|
528
|
+
name: "Session IDs are high-entropy",
|
|
529
|
+
category: "security",
|
|
530
|
+
required: false,
|
|
531
|
+
specRef: "basic/transports#streamable-http",
|
|
532
|
+
description: "Analyzes the MCP-Session-Id returned by the server. Session IDs should be cryptographically random and not sequential or predictable.",
|
|
533
|
+
recommendation: "Generate session IDs using a cryptographically secure random source (e.g., crypto.randomUUID()). Session IDs should be at least 128 bits of entropy. Do not use sequential counters or timestamps."
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
id: "security-session-not-auth",
|
|
537
|
+
name: "Session ID does not bypass auth",
|
|
538
|
+
category: "security",
|
|
539
|
+
required: false,
|
|
540
|
+
specRef: "basic/transports#streamable-http",
|
|
541
|
+
description: "Verifies that presenting a valid MCP-Session-Id without an Authorization header is still rejected. Per spec, servers MUST NOT use sessions for authentication.",
|
|
542
|
+
recommendation: "Always validate the Authorization header independently of the MCP-Session-Id. Sessions are for request routing, not authentication. Reject requests that lack valid auth even if they have a valid session ID."
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: "security-oauth-metadata",
|
|
546
|
+
name: "OAuth metadata endpoint exists",
|
|
547
|
+
category: "security",
|
|
548
|
+
required: false,
|
|
549
|
+
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."
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: "security-token-in-uri",
|
|
555
|
+
name: "Rejects auth tokens in query string",
|
|
556
|
+
category: "security",
|
|
557
|
+
required: false,
|
|
558
|
+
specRef: "basic/authorization",
|
|
559
|
+
description: "Sends a request with the auth token in the URL query string instead of the Authorization header. The MCP spec forbids transmitting credentials in URIs.",
|
|
560
|
+
recommendation: "Never accept authentication tokens from URL query parameters. Tokens in URIs are logged by proxies, appear in browser history, and leak via the Referer header. Only accept tokens in the Authorization header."
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: "security-cors-headers",
|
|
564
|
+
name: "CORS headers are restrictive",
|
|
565
|
+
category: "security",
|
|
566
|
+
required: false,
|
|
567
|
+
specRef: "basic/transports#streamable-http",
|
|
568
|
+
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
|
+
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
|
+
},
|
|
571
|
+
// ── Security: Input Validation (6 tests) ─────────────────────────
|
|
572
|
+
{
|
|
573
|
+
id: "security-command-injection",
|
|
574
|
+
name: "Resists command injection in tool params",
|
|
575
|
+
category: "security",
|
|
576
|
+
required: false,
|
|
577
|
+
specRef: "server/tools#calling-tools",
|
|
578
|
+
description: "Calls each tool with OS command injection payloads in string parameters (e.g., '; cat /etc/passwd', '$(whoami)'). Verifies the server does not execute injected commands.",
|
|
579
|
+
recommendation: "Never pass tool argument values directly to shell commands. Use parameterized APIs, execFile() instead of exec(), or allowlists. Sanitize all user-provided input before use in system calls."
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
id: "security-sql-injection",
|
|
583
|
+
name: "Resists SQL injection in tool params",
|
|
584
|
+
category: "security",
|
|
585
|
+
required: false,
|
|
586
|
+
specRef: "server/tools#calling-tools",
|
|
587
|
+
description: `Calls each tool with SQL injection payloads in string parameters (e.g., "' OR 1=1 --"). Verifies the server does not return database errors or unexpected data.`,
|
|
588
|
+
recommendation: "Use parameterized queries or prepared statements for all database operations. Never concatenate user input into SQL strings. Return generic error messages that do not reveal database structure."
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
id: "security-path-traversal",
|
|
592
|
+
name: "Resists path traversal in tool params",
|
|
593
|
+
category: "security",
|
|
594
|
+
required: false,
|
|
595
|
+
specRef: "server/tools#calling-tools",
|
|
596
|
+
description: "Calls each tool with path traversal payloads in string parameters (e.g., '../../etc/passwd', '..\\\\..\\\\windows\\\\system.ini'). Verifies the server does not expose files outside its intended scope.",
|
|
597
|
+
recommendation: "Validate and sanitize file paths. Use path.resolve() and verify the result is within the allowed directory. Reject paths containing '..' segments. Use a chroot or sandboxed filesystem for file operations."
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: "security-ssrf-internal",
|
|
601
|
+
name: "Resists SSRF to internal networks",
|
|
602
|
+
category: "security",
|
|
603
|
+
required: false,
|
|
604
|
+
specRef: "server/tools#calling-tools",
|
|
605
|
+
description: "For tools that accept URL parameters, submits internal IP addresses (169.254.169.254, 127.0.0.1, 10.0.0.0/8) and cloud metadata endpoints. Verifies the server blocks requests to internal networks.",
|
|
606
|
+
recommendation: "Validate and restrict URLs in tool parameters. Block requests to private IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x), link-local addresses, and cloud metadata endpoints. Use an allowlist of permitted domains."
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: "security-oversized-input",
|
|
610
|
+
name: "Handles oversized inputs gracefully",
|
|
611
|
+
category: "security",
|
|
612
|
+
required: false,
|
|
613
|
+
specRef: "server/tools#calling-tools",
|
|
614
|
+
description: "Sends a tools/call request with an extremely large argument value (1MB+ string). Verifies the server rejects it with an error instead of crashing or consuming excessive resources.",
|
|
615
|
+
recommendation: "Implement request body size limits. Return HTTP 413 or JSON-RPC error for oversized payloads. Set explicit maxBodyLength in your HTTP server configuration."
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
id: "security-extra-params",
|
|
619
|
+
name: "Rejects or ignores extra tool params",
|
|
620
|
+
category: "security",
|
|
621
|
+
required: false,
|
|
622
|
+
specRef: "server/tools#calling-tools",
|
|
623
|
+
description: "Calls a tool with unexpected additional parameters beyond what the schema defines. Verifies the server either rejects them (strict) or silently ignores them (permissive) without errors.",
|
|
624
|
+
recommendation: "Use JSON Schema validation with additionalProperties: false to reject unexpected parameters, or strip unknown properties before processing. Do not pass unvalidated properties to internal functions."
|
|
625
|
+
},
|
|
626
|
+
// ── Security: Tool Integrity (4 tests) ───────────────────────────
|
|
627
|
+
{
|
|
628
|
+
id: "security-tool-schema-defined",
|
|
629
|
+
name: "All tools define inputSchema",
|
|
630
|
+
category: "security",
|
|
631
|
+
required: false,
|
|
632
|
+
specRef: "server/tools#data-types",
|
|
633
|
+
description: "Verifies all tools have an inputSchema with type 'object'. Tools without schemas cannot have their inputs validated, creating an injection risk.",
|
|
634
|
+
recommendation: "Define a complete JSON Schema (inputSchema with type: 'object') for every tool. Specify all expected properties, their types, and constraints. This enables input validation and prevents parameter injection."
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
id: "security-tool-rug-pull",
|
|
638
|
+
name: "Tool definitions are stable across calls",
|
|
639
|
+
category: "security",
|
|
640
|
+
required: false,
|
|
641
|
+
specRef: "server/tools#listing-tools",
|
|
642
|
+
description: "Calls tools/list twice and compares the results. Tool definitions should not change between calls within the same session, which could indicate a rug-pull attack.",
|
|
643
|
+
recommendation: "Ensure tools/list returns consistent results within a session. If tools change dynamically, send a tools/list_changed notification. Never silently alter tool definitions \u2014 this is a known MCP attack vector (tool poisoning)."
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: "security-tool-description-poisoning",
|
|
647
|
+
name: "Tool descriptions free of injection patterns",
|
|
648
|
+
category: "security",
|
|
649
|
+
required: false,
|
|
650
|
+
specRef: "server/tools#data-types",
|
|
651
|
+
description: "Scans all tool names, descriptions, and parameter descriptions for prompt injection patterns: 'ignore previous', 'override', 'system prompt', hidden Unicode characters, and Base64-encoded strings.",
|
|
652
|
+
recommendation: "Review all tool descriptions for prompt injection patterns. Remove any text that attempts to override LLM instructions, references system prompts, or contains hidden characters. Tool descriptions are rendered to LLMs and can be used for prompt injection."
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: "security-tool-cross-reference",
|
|
656
|
+
name: "Tools do not reference other tools by name",
|
|
657
|
+
category: "security",
|
|
658
|
+
required: false,
|
|
659
|
+
specRef: "server/tools#data-types",
|
|
660
|
+
description: "Checks that tool descriptions do not reference other tool names. Cross-references between tools can be used to manipulate LLM tool selection and create implicit execution chains.",
|
|
661
|
+
recommendation: "Avoid referencing other tool names in tool descriptions. Each tool should be self-contained. If tools have dependencies, document them in server instructions, not in individual tool descriptions."
|
|
662
|
+
},
|
|
663
|
+
// ── Security: Information Disclosure (3 tests) ───────────────────
|
|
664
|
+
{
|
|
665
|
+
id: "security-error-no-stacktrace",
|
|
666
|
+
name: "Error responses do not leak stack traces",
|
|
667
|
+
category: "security",
|
|
668
|
+
required: false,
|
|
669
|
+
specRef: "basic",
|
|
670
|
+
description: "Triggers various error conditions and inspects responses for stack traces, file paths, and internal implementation details. Error responses should not reveal server internals.",
|
|
671
|
+
recommendation: "Sanitize error responses before returning them to clients. Remove stack traces, file paths, database connection strings, and internal IP addresses. Use generic error messages for unexpected failures."
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "security-error-no-internal-ip",
|
|
675
|
+
name: "Error responses do not leak internal IPs",
|
|
676
|
+
category: "security",
|
|
677
|
+
required: false,
|
|
678
|
+
specRef: "basic",
|
|
679
|
+
description: "Inspects error response bodies for private IP addresses (10.x, 172.16-31.x, 192.168.x, 127.x) that would reveal internal network topology.",
|
|
680
|
+
recommendation: "Strip internal IP addresses from error responses. Configure your reverse proxy to not forward X-Real-IP or internal addressing. Use a centralized error handler that sanitizes responses."
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
id: "security-rate-limiting",
|
|
684
|
+
name: "Rate limiting is enforced",
|
|
685
|
+
category: "security",
|
|
686
|
+
required: false,
|
|
687
|
+
specRef: "basic/transports#streamable-http",
|
|
688
|
+
description: "Sends a burst of rapid requests and checks if the server eventually returns HTTP 429 Too Many Requests. Production servers should implement rate limiting to prevent abuse.",
|
|
689
|
+
recommendation: "Implement rate limiting on your MCP endpoint. Return HTTP 429 with a Retry-After header when limits are exceeded. Consider per-IP, per-token, and per-session rate limits."
|
|
454
690
|
}
|
|
455
691
|
];
|
|
456
692
|
|
|
457
693
|
// src/runner.ts
|
|
694
|
+
var TEST_DEFINITIONS_MAP = new Map(TEST_DEFINITIONS.map((t) => [t.id, t]));
|
|
458
695
|
var _require = createRequire(import.meta.url);
|
|
459
696
|
var { version: TOOL_VERSION } = _require("../package.json");
|
|
460
697
|
var SPEC_VERSION = "2025-11-25";
|
|
461
698
|
var SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`;
|
|
462
699
|
var VALID_CONTENT_TYPES = ["text", "image", "audio", "resource", "resource_link"];
|
|
700
|
+
var INJECTION_PAYLOADS = {
|
|
701
|
+
command: ["; cat /etc/passwd", "$(whoami)", "| ls -la", "`id`", "&& echo pwned"],
|
|
702
|
+
sql: ["' OR 1=1 --", "'; DROP TABLE users; --", "1 UNION SELECT * FROM information_schema.tables--"],
|
|
703
|
+
pathTraversal: ["../../etc/passwd", "..\\..\\windows\\system.ini", "%2e%2e%2f%2e%2e%2fetc%2fpasswd"],
|
|
704
|
+
ssrf: ["http://169.254.169.254/latest/meta-data/", "http://127.0.0.1:22", "http://[::1]/", "http://10.0.0.1/"]
|
|
705
|
+
};
|
|
706
|
+
var STACK_TRACE_PATTERNS = [
|
|
707
|
+
/at\s+\S+\s+\(.*:\d+:\d+\)/i,
|
|
708
|
+
// Node.js: "at Function (file.js:10:5)"
|
|
709
|
+
/Traceback\s+\(most recent/i,
|
|
710
|
+
// Python
|
|
711
|
+
/\.py",\s+line\s+\d+/i,
|
|
712
|
+
// Python file reference
|
|
713
|
+
/\.java:\d+\)/i,
|
|
714
|
+
// Java
|
|
715
|
+
/\.go:\d+/i,
|
|
716
|
+
// Go
|
|
717
|
+
/from\s+\S+\.rb:\d+/i,
|
|
718
|
+
// Ruby
|
|
719
|
+
/\.cs:line\s+\d+/i,
|
|
720
|
+
// C#/.NET
|
|
721
|
+
/#\d+\s+\/.*\.php\(\d+\)/i,
|
|
722
|
+
// PHP
|
|
723
|
+
/panicked\s+at\s+'/i,
|
|
724
|
+
// Rust
|
|
725
|
+
/ENOENT|EACCES|EPERM/,
|
|
726
|
+
// Node.js system errors
|
|
727
|
+
/node_modules\//,
|
|
728
|
+
// Node.js module paths
|
|
729
|
+
/\/usr\/local\/|\/home\//,
|
|
730
|
+
// Unix paths
|
|
731
|
+
/[A-Z]:\\.*\\/,
|
|
732
|
+
// Windows paths
|
|
733
|
+
/password|passwd|secret|credential/i,
|
|
734
|
+
// Sensitive terms
|
|
735
|
+
/jdbc:|mysql:|postgres:|mongodb:/i
|
|
736
|
+
// DB connection strings
|
|
737
|
+
];
|
|
738
|
+
var INTERNAL_IP_PATTERNS = [
|
|
739
|
+
/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
740
|
+
/\b172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/,
|
|
741
|
+
/\b192\.168\.\d{1,3}\.\d{1,3}\b/,
|
|
742
|
+
/\b127\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
743
|
+
/\b::1\b/,
|
|
744
|
+
// IPv6 loopback
|
|
745
|
+
/\bfe80:/i,
|
|
746
|
+
// IPv6 link-local
|
|
747
|
+
/\bf[cd][0-9a-f]{2}:/i
|
|
748
|
+
// IPv6 unique local (fc00::/fd00::)
|
|
749
|
+
];
|
|
463
750
|
function createIdCounter(start = 0) {
|
|
464
751
|
let id = start;
|
|
465
752
|
return () => ++id;
|
|
@@ -554,19 +841,39 @@ async function mcpNotification(backendUrl, method, params, extraHeaders, timeout
|
|
|
554
841
|
return { statusCode: res.statusCode, headers: responseHeaders };
|
|
555
842
|
}
|
|
556
843
|
async function runComplianceSuite(url, options = {}) {
|
|
557
|
-
let parsed;
|
|
558
844
|
try {
|
|
559
|
-
parsed = new URL(url);
|
|
845
|
+
const parsed = new URL(url);
|
|
560
846
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
561
847
|
throw new Error("Only HTTP and HTTPS URLs are supported");
|
|
562
848
|
}
|
|
563
849
|
} catch (e) {
|
|
564
|
-
if (e.message.includes("Only HTTP")) throw e;
|
|
850
|
+
if (e instanceof Error && e.message.includes("Only HTTP")) throw e;
|
|
565
851
|
throw new Error(`Invalid URL: ${url}`);
|
|
566
852
|
}
|
|
567
853
|
const backendUrl = url;
|
|
854
|
+
let serverReachable = true;
|
|
855
|
+
try {
|
|
856
|
+
const preflight = await request(backendUrl, {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: {
|
|
859
|
+
"Content-Type": "application/json",
|
|
860
|
+
Accept: "application/json, text/event-stream",
|
|
861
|
+
...options.headers
|
|
862
|
+
},
|
|
863
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
|
|
864
|
+
signal: AbortSignal.timeout(Math.min(options.timeout || 15e3, 1e4))
|
|
865
|
+
});
|
|
866
|
+
await preflight.body.text();
|
|
867
|
+
} catch {
|
|
868
|
+
serverReachable = false;
|
|
869
|
+
}
|
|
568
870
|
const tests = [];
|
|
569
871
|
const warnings = [];
|
|
872
|
+
if (!serverReachable) {
|
|
873
|
+
warnings.push(
|
|
874
|
+
`Server at ${url} is unreachable \u2014 all tests will fail. Check the URL and ensure the server is running.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
570
877
|
const nextId = createIdCounter(1e3);
|
|
571
878
|
const timeout = options.timeout || 15e3;
|
|
572
879
|
const retries = options.retries || 0;
|
|
@@ -611,7 +918,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
611
918
|
if (lastResult.passed) break;
|
|
612
919
|
if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
|
|
613
920
|
} catch (err) {
|
|
614
|
-
|
|
921
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
922
|
+
lastResult = { passed: false, details: `Error: ${message}` };
|
|
615
923
|
if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
|
|
616
924
|
}
|
|
617
925
|
}
|
|
@@ -640,10 +948,23 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
640
948
|
body: JSON.stringify({ jsonrpc: "2.0", id: 99901, method: "ping" }),
|
|
641
949
|
signal: AbortSignal.timeout(timeout)
|
|
642
950
|
});
|
|
643
|
-
await res.body.text();
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
951
|
+
const text = await res.body.text();
|
|
952
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
953
|
+
return { passed: true, details: `HTTP ${res.statusCode}` };
|
|
954
|
+
}
|
|
955
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
956
|
+
return { passed: false, details: `HTTP ${res.statusCode} (auth required \u2014 pass --auth)` };
|
|
957
|
+
}
|
|
958
|
+
if (res.statusCode === 400) {
|
|
959
|
+
try {
|
|
960
|
+
const body = JSON.parse(text);
|
|
961
|
+
if (body?.error || body?.jsonrpc) {
|
|
962
|
+
return { passed: true, details: "HTTP 400 with JSON-RPC response (server requires initialization first)" };
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
647
968
|
}
|
|
648
969
|
);
|
|
649
970
|
await test(
|
|
@@ -673,18 +994,29 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
673
994
|
false,
|
|
674
995
|
"basic/transports#streamable-http",
|
|
675
996
|
async () => {
|
|
997
|
+
const getHeaders = { Accept: "text/event-stream", ...buildHeaders() };
|
|
676
998
|
const res = await request(backendUrl, {
|
|
677
999
|
method: "GET",
|
|
678
|
-
headers:
|
|
1000
|
+
headers: getHeaders,
|
|
679
1001
|
signal: AbortSignal.timeout(timeout)
|
|
680
1002
|
});
|
|
681
|
-
await res.body.text();
|
|
1003
|
+
const body = await res.body.text();
|
|
682
1004
|
const ct = (res.headers["content-type"] || "").toLowerCase();
|
|
683
1005
|
if (res.statusCode === 405) {
|
|
684
1006
|
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
685
1007
|
}
|
|
686
1008
|
if (ct.includes("text/event-stream")) {
|
|
687
|
-
|
|
1009
|
+
if (body.trim().length > 0) {
|
|
1010
|
+
const hasDataFields = body.includes("data:");
|
|
1011
|
+
const hasEventFields = body.includes("event:");
|
|
1012
|
+
if (!hasDataFields && !hasEventFields) {
|
|
1013
|
+
return {
|
|
1014
|
+
passed: false,
|
|
1015
|
+
details: "Content-Type is text/event-stream but body has no SSE data: or event: fields"
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return { passed: true, details: "Returns text/event-stream with valid SSE format" };
|
|
688
1020
|
}
|
|
689
1021
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
690
1022
|
return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
|
|
@@ -692,31 +1024,6 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
692
1024
|
return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
|
|
693
1025
|
}
|
|
694
1026
|
);
|
|
695
|
-
await test(
|
|
696
|
-
"transport-delete",
|
|
697
|
-
"DELETE accepted or returns 405",
|
|
698
|
-
"transport",
|
|
699
|
-
false,
|
|
700
|
-
"basic/transports#streamable-http",
|
|
701
|
-
async () => {
|
|
702
|
-
const res = await request(backendUrl, {
|
|
703
|
-
method: "DELETE",
|
|
704
|
-
headers: { ...userHeaders },
|
|
705
|
-
signal: AbortSignal.timeout(timeout)
|
|
706
|
-
});
|
|
707
|
-
await res.body.text();
|
|
708
|
-
if (res.statusCode === 405) {
|
|
709
|
-
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
710
|
-
}
|
|
711
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
712
|
-
return { passed: true, details: `HTTP ${res.statusCode} (session termination supported)` };
|
|
713
|
-
}
|
|
714
|
-
if (res.statusCode === 400 || res.statusCode === 404) {
|
|
715
|
-
return { passed: true, details: `HTTP ${res.statusCode} (no active session, acceptable)` };
|
|
716
|
-
}
|
|
717
|
-
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
718
|
-
}
|
|
719
|
-
);
|
|
720
1027
|
await test(
|
|
721
1028
|
"transport-batch-reject",
|
|
722
1029
|
"Rejects JSON-RPC batch requests",
|
|
@@ -754,7 +1061,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
754
1061
|
try {
|
|
755
1062
|
initRes = await rpc("initialize", {
|
|
756
1063
|
protocolVersion: SPEC_VERSION,
|
|
757
|
-
capabilities: {
|
|
1064
|
+
capabilities: {},
|
|
758
1065
|
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
759
1066
|
});
|
|
760
1067
|
const result = initRes?.body?.result;
|
|
@@ -767,7 +1074,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
767
1074
|
if (sid) sessionId = sid;
|
|
768
1075
|
if (result.protocolVersion) negotiatedProtocolVersion = result.protocolVersion;
|
|
769
1076
|
}
|
|
770
|
-
} catch
|
|
1077
|
+
} catch {
|
|
771
1078
|
}
|
|
772
1079
|
try {
|
|
773
1080
|
await mcpNotification(backendUrl, "notifications/initialized", void 0, buildHeaders(), timeout);
|
|
@@ -886,7 +1193,17 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
886
1193
|
if (res.body?.error) {
|
|
887
1194
|
return { passed: false, details: `Error: ${res.body.error.code} \u2014 ${res.body.error.message}` };
|
|
888
1195
|
}
|
|
889
|
-
|
|
1196
|
+
const invalidRes = await rpc("logging/setLevel", { level: "__invalid_level__" });
|
|
1197
|
+
const validatesInput = !!invalidRes.body?.error;
|
|
1198
|
+
const validLevels = ["debug", "warning", "error"];
|
|
1199
|
+
const accepted = [];
|
|
1200
|
+
for (const level of validLevels) {
|
|
1201
|
+
const r = await rpc("logging/setLevel", { level });
|
|
1202
|
+
if (!r.body?.error) accepted.push(level);
|
|
1203
|
+
}
|
|
1204
|
+
const details = validatesInput ? `logging/setLevel accepted (validates levels, ${accepted.length + 1} levels accepted)` : "logging/setLevel accepted (warning: server does not reject invalid log levels)";
|
|
1205
|
+
if (!validatesInput) warnings.push("Server accepts invalid log levels without error");
|
|
1206
|
+
return { passed: true, details };
|
|
890
1207
|
}
|
|
891
1208
|
);
|
|
892
1209
|
const hasCompletions = !!serverInfo.capabilities.completions;
|
|
@@ -915,6 +1232,59 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
915
1232
|
return { passed: true, details: "completion/complete accepted" };
|
|
916
1233
|
}
|
|
917
1234
|
);
|
|
1235
|
+
await test(
|
|
1236
|
+
"lifecycle-cancellation",
|
|
1237
|
+
"Handles cancellation notifications",
|
|
1238
|
+
"lifecycle",
|
|
1239
|
+
false,
|
|
1240
|
+
"basic/utilities#cancellation",
|
|
1241
|
+
async () => {
|
|
1242
|
+
const res = await mcpNotification(
|
|
1243
|
+
backendUrl,
|
|
1244
|
+
"notifications/cancelled",
|
|
1245
|
+
{ requestId: 99999, reason: "compliance test" },
|
|
1246
|
+
buildHeaders(),
|
|
1247
|
+
timeout
|
|
1248
|
+
);
|
|
1249
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1250
|
+
return { passed: true, details: `HTTP ${res.statusCode} (cancellation accepted)` };
|
|
1251
|
+
}
|
|
1252
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept cancellation notifications` };
|
|
1253
|
+
}
|
|
1254
|
+
);
|
|
1255
|
+
await test(
|
|
1256
|
+
"lifecycle-progress",
|
|
1257
|
+
"Accepts progress notifications",
|
|
1258
|
+
"lifecycle",
|
|
1259
|
+
false,
|
|
1260
|
+
"basic/utilities#progress",
|
|
1261
|
+
async () => {
|
|
1262
|
+
const res = await mcpNotification(
|
|
1263
|
+
backendUrl,
|
|
1264
|
+
"notifications/progress",
|
|
1265
|
+
{ progressToken: "compliance-test-token", progress: 50, total: 100 },
|
|
1266
|
+
buildHeaders(),
|
|
1267
|
+
timeout
|
|
1268
|
+
);
|
|
1269
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1270
|
+
return { passed: true, details: `HTTP ${res.statusCode} (progress notification accepted)` };
|
|
1271
|
+
}
|
|
1272
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept progress notifications` };
|
|
1273
|
+
}
|
|
1274
|
+
);
|
|
1275
|
+
await test(
|
|
1276
|
+
"transport-content-type-init",
|
|
1277
|
+
"Initialize response has valid content type",
|
|
1278
|
+
"transport",
|
|
1279
|
+
false,
|
|
1280
|
+
"basic/transports#streamable-http",
|
|
1281
|
+
async () => {
|
|
1282
|
+
if (!initRes) return { passed: false, details: "No init response to check" };
|
|
1283
|
+
const ct = (initRes.headers["content-type"] || "").toLowerCase();
|
|
1284
|
+
const valid = ct.includes("application/json") || ct.includes("text/event-stream");
|
|
1285
|
+
return { passed: valid, details: `Content-Type: ${ct || "missing"}` };
|
|
1286
|
+
}
|
|
1287
|
+
);
|
|
918
1288
|
await test(
|
|
919
1289
|
"transport-notification-202",
|
|
920
1290
|
"Notification returns 202 Accepted",
|
|
@@ -972,6 +1342,88 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
972
1342
|
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
973
1343
|
}
|
|
974
1344
|
);
|
|
1345
|
+
await test(
|
|
1346
|
+
"transport-get-stream",
|
|
1347
|
+
"GET with session returns SSE or 405",
|
|
1348
|
+
"transport",
|
|
1349
|
+
false,
|
|
1350
|
+
"basic/transports#streamable-http",
|
|
1351
|
+
async () => {
|
|
1352
|
+
if (!sessionId) {
|
|
1353
|
+
return { passed: true, details: "No session ID \u2014 server-initiated messages not applicable" };
|
|
1354
|
+
}
|
|
1355
|
+
const res = await request(backendUrl, {
|
|
1356
|
+
method: "GET",
|
|
1357
|
+
headers: { Accept: "text/event-stream", ...buildHeaders() },
|
|
1358
|
+
signal: AbortSignal.timeout(Math.min(timeout, 3e3))
|
|
1359
|
+
});
|
|
1360
|
+
const body = await res.body.text();
|
|
1361
|
+
const ct = (res.headers["content-type"] || "").toLowerCase();
|
|
1362
|
+
if (res.statusCode === 405) {
|
|
1363
|
+
return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
|
|
1364
|
+
}
|
|
1365
|
+
if (ct.includes("text/event-stream")) {
|
|
1366
|
+
if (body.trim().length > 0) {
|
|
1367
|
+
const hasSSEFields = body.includes("data:") || body.includes("event:");
|
|
1368
|
+
if (!hasSSEFields) {
|
|
1369
|
+
return { passed: false, details: "Content-Type is text/event-stream but body has no SSE fields" };
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return { passed: true, details: "GET with session returns SSE stream for server-initiated messages" };
|
|
1373
|
+
}
|
|
1374
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1375
|
+
return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
|
|
1376
|
+
}
|
|
1377
|
+
return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
|
|
1378
|
+
}
|
|
1379
|
+
);
|
|
1380
|
+
await test(
|
|
1381
|
+
"transport-concurrent",
|
|
1382
|
+
"Handles concurrent requests",
|
|
1383
|
+
"transport",
|
|
1384
|
+
false,
|
|
1385
|
+
"basic/transports#streamable-http",
|
|
1386
|
+
async () => {
|
|
1387
|
+
const ids = [createIdCounter(99930)(), createIdCounter(99931)(), createIdCounter(99932)()];
|
|
1388
|
+
const promises = ids.map(
|
|
1389
|
+
(id) => request(backendUrl, {
|
|
1390
|
+
method: "POST",
|
|
1391
|
+
headers: {
|
|
1392
|
+
"Content-Type": "application/json",
|
|
1393
|
+
Accept: "application/json, text/event-stream",
|
|
1394
|
+
...buildHeaders()
|
|
1395
|
+
},
|
|
1396
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method: "ping" }),
|
|
1397
|
+
signal: AbortSignal.timeout(timeout)
|
|
1398
|
+
}).then(async (res) => {
|
|
1399
|
+
const text = await res.body.text();
|
|
1400
|
+
const ct = (res.headers["content-type"] || "").toLowerCase();
|
|
1401
|
+
let body;
|
|
1402
|
+
if (ct.includes("text/event-stream")) {
|
|
1403
|
+
body = parseSSEResponse(text);
|
|
1404
|
+
}
|
|
1405
|
+
if (!body) {
|
|
1406
|
+
try {
|
|
1407
|
+
body = JSON.parse(text);
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return { statusCode: res.statusCode, body, requestId: id };
|
|
1412
|
+
})
|
|
1413
|
+
);
|
|
1414
|
+
const results = await Promise.all(promises);
|
|
1415
|
+
const issues = [];
|
|
1416
|
+
for (const r of results) {
|
|
1417
|
+
if (r.statusCode < 200 || r.statusCode >= 300) {
|
|
1418
|
+
issues.push(`Request id=${r.requestId}: HTTP ${r.statusCode}`);
|
|
1419
|
+
} else if (r.body?.id !== r.requestId) {
|
|
1420
|
+
issues.push(`Request id=${r.requestId}: response id=${r.body?.id} (mismatch)`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1424
|
+
return { passed: true, details: `${results.length} concurrent requests handled correctly` };
|
|
1425
|
+
}
|
|
1426
|
+
);
|
|
975
1427
|
const hasTools = !!serverInfo.capabilities.tools;
|
|
976
1428
|
let cachedToolsList = null;
|
|
977
1429
|
await test(
|
|
@@ -993,6 +1445,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
993
1445
|
};
|
|
994
1446
|
}
|
|
995
1447
|
);
|
|
1448
|
+
const toolsListOk = cachedToolsList !== null;
|
|
996
1449
|
await test(
|
|
997
1450
|
"tools-schema",
|
|
998
1451
|
"All tools have name and inputSchema",
|
|
@@ -1000,7 +1453,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1000
1453
|
hasTools,
|
|
1001
1454
|
"server/tools#data-types",
|
|
1002
1455
|
async () => {
|
|
1003
|
-
|
|
1456
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
1457
|
+
const tools = cachedToolsList ?? [];
|
|
1004
1458
|
const issues = [];
|
|
1005
1459
|
for (const tool of tools) {
|
|
1006
1460
|
if (!tool.name) {
|
|
@@ -1032,7 +1486,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1032
1486
|
false,
|
|
1033
1487
|
"server/tools#annotations",
|
|
1034
1488
|
async () => {
|
|
1035
|
-
|
|
1489
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
1490
|
+
const tools = cachedToolsList ?? [];
|
|
1036
1491
|
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
1037
1492
|
const issues = [];
|
|
1038
1493
|
let annotatedCount = 0;
|
|
@@ -1062,7 +1517,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1062
1517
|
}
|
|
1063
1518
|
);
|
|
1064
1519
|
await test("tools-title-field", "Tools include title field", "schema", false, "server/tools#data-types", async () => {
|
|
1065
|
-
|
|
1520
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
1521
|
+
const tools = cachedToolsList ?? [];
|
|
1066
1522
|
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
1067
1523
|
const withTitle = tools.filter((t) => typeof t.title === "string");
|
|
1068
1524
|
const issues = [];
|
|
@@ -1084,7 +1540,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1084
1540
|
false,
|
|
1085
1541
|
"server/tools#structured-content",
|
|
1086
1542
|
async () => {
|
|
1087
|
-
|
|
1543
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
1544
|
+
const tools = cachedToolsList ?? [];
|
|
1088
1545
|
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
1089
1546
|
const issues = [];
|
|
1090
1547
|
let withSchema = 0;
|
|
@@ -1125,14 +1582,14 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1125
1582
|
return { passed: true, details: `Protocol error: code ${code} \u2014 ${error.message}` };
|
|
1126
1583
|
}
|
|
1127
1584
|
if (result?.content && Array.isArray(result.content)) {
|
|
1585
|
+
if (result.isError) {
|
|
1586
|
+
return { passed: true, details: "Tool returned execution error with content (valid)" };
|
|
1587
|
+
}
|
|
1128
1588
|
const badItems = result.content.filter((c) => !c.type);
|
|
1129
1589
|
if (badItems.length > 0)
|
|
1130
1590
|
return { passed: false, details: `${badItems.length} content item(s) missing 'type' field` };
|
|
1131
1591
|
return { passed: true, details: `Returned ${result.content.length} content item(s)` };
|
|
1132
1592
|
}
|
|
1133
|
-
if (result?.isError && result?.content && Array.isArray(result.content)) {
|
|
1134
|
-
return { passed: true, details: "Tool returned execution error with content (valid)" };
|
|
1135
|
-
}
|
|
1136
1593
|
return { passed: false, details: "Response missing content array" };
|
|
1137
1594
|
}
|
|
1138
1595
|
);
|
|
@@ -1234,6 +1691,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1234
1691
|
return { passed: true, details: `${resourceCount} resource(s)` };
|
|
1235
1692
|
}
|
|
1236
1693
|
);
|
|
1694
|
+
const resourcesListOk = cachedResourcesList !== null;
|
|
1237
1695
|
await test(
|
|
1238
1696
|
"resources-schema",
|
|
1239
1697
|
"Resources have uri and name",
|
|
@@ -1241,7 +1699,8 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1241
1699
|
true,
|
|
1242
1700
|
"server/resources#data-types",
|
|
1243
1701
|
async () => {
|
|
1244
|
-
|
|
1702
|
+
if (!resourcesListOk) return { passed: false, details: "Skipped: resources/list failed" };
|
|
1703
|
+
const resources = cachedResourcesList ?? [];
|
|
1245
1704
|
const issues = [];
|
|
1246
1705
|
for (const r of resources) {
|
|
1247
1706
|
if (!r.uri) issues.push("Resource missing uri");
|
|
@@ -1270,7 +1729,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1270
1729
|
false,
|
|
1271
1730
|
"server/resources#reading-resources",
|
|
1272
1731
|
async () => {
|
|
1273
|
-
const resources = cachedResourcesList ??
|
|
1732
|
+
const resources = cachedResourcesList ?? [];
|
|
1274
1733
|
const firstUri = resources[0]?.uri;
|
|
1275
1734
|
if (!firstUri) return { passed: false, details: "No resource URI to test" };
|
|
1276
1735
|
const readRes = await rpc("resources/read", { uri: firstUri });
|
|
@@ -1303,8 +1762,15 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1303
1762
|
if (!Array.isArray(templates)) return { passed: false, details: "No resourceTemplates array" };
|
|
1304
1763
|
const issues = [];
|
|
1305
1764
|
for (const t of templates) {
|
|
1306
|
-
if (!t.uriTemplate)
|
|
1765
|
+
if (!t.uriTemplate) {
|
|
1766
|
+
issues.push("Template missing uriTemplate");
|
|
1767
|
+
} else if (typeof t.uriTemplate !== "string") {
|
|
1768
|
+
issues.push(`uriTemplate should be a string, got ${typeof t.uriTemplate}`);
|
|
1769
|
+
} else if (!t.uriTemplate.includes("{") || !t.uriTemplate.includes("}")) {
|
|
1770
|
+
warnings.push(`Template "${t.name || t.uriTemplate}" has no URI template parameters (e.g., {id})`);
|
|
1771
|
+
}
|
|
1307
1772
|
if (!t.name) issues.push(`${t.uriTemplate || "?"}: missing name`);
|
|
1773
|
+
if (!t.description) warnings.push(`Template "${t.name || t.uriTemplate || "?"}" missing description`);
|
|
1308
1774
|
}
|
|
1309
1775
|
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1310
1776
|
return { passed: true, details: `${templates.length} resource template(s)` };
|
|
@@ -1346,7 +1812,7 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1346
1812
|
true,
|
|
1347
1813
|
"server/resources#subscriptions",
|
|
1348
1814
|
async () => {
|
|
1349
|
-
const resources = cachedResourcesList ??
|
|
1815
|
+
const resources = cachedResourcesList ?? [];
|
|
1350
1816
|
const firstUri = resources[0]?.uri;
|
|
1351
1817
|
if (!firstUri) return { passed: false, details: "No resource URI for subscribe test" };
|
|
1352
1818
|
const subRes = await rpc("resources/subscribe", { uri: firstUri });
|
|
@@ -1390,8 +1856,10 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1390
1856
|
};
|
|
1391
1857
|
}
|
|
1392
1858
|
);
|
|
1859
|
+
const promptsListOk = cachedPromptsList !== null;
|
|
1393
1860
|
await test("prompts-schema", "Prompts have name field", "schema", true, "server/prompts#data-types", async () => {
|
|
1394
|
-
|
|
1861
|
+
if (!promptsListOk) return { passed: false, details: "Skipped: prompts/list failed" };
|
|
1862
|
+
const prompts = cachedPromptsList ?? [];
|
|
1395
1863
|
const issues = [];
|
|
1396
1864
|
for (const p of prompts) {
|
|
1397
1865
|
if (!p.name) issues.push("Prompt missing name");
|
|
@@ -1587,6 +2055,750 @@ async function runComplianceSuite(url, options = {}) {
|
|
|
1587
2055
|
}
|
|
1588
2056
|
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
|
|
1589
2057
|
});
|
|
2058
|
+
const hasAuth = !!userHeaders.Authorization || !!userHeaders.authorization;
|
|
2059
|
+
await test(
|
|
2060
|
+
"security-auth-required",
|
|
2061
|
+
"Rejects unauthenticated requests",
|
|
2062
|
+
"security",
|
|
2063
|
+
false,
|
|
2064
|
+
"basic/authorization",
|
|
2065
|
+
async () => {
|
|
2066
|
+
if (!hasAuth) {
|
|
2067
|
+
return {
|
|
2068
|
+
passed: false,
|
|
2069
|
+
details: "Server does not require auth (no --auth provided and server accepted unauthenticated requests)"
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
const noAuthHeaders = {};
|
|
2073
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
2074
|
+
try {
|
|
2075
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
2076
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
2077
|
+
return { passed: true, details: `HTTP ${res.statusCode} (unauthenticated request rejected)` };
|
|
2078
|
+
}
|
|
2079
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepted unauthenticated request` };
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
);
|
|
2085
|
+
await test(
|
|
2086
|
+
"security-auth-malformed",
|
|
2087
|
+
"Rejects malformed auth credentials",
|
|
2088
|
+
"security",
|
|
2089
|
+
false,
|
|
2090
|
+
"basic/authorization",
|
|
2091
|
+
async () => {
|
|
2092
|
+
if (!hasAuth) {
|
|
2093
|
+
return { passed: false, details: "Skipped: server does not require auth" };
|
|
2094
|
+
}
|
|
2095
|
+
const malformedHeaders = {
|
|
2096
|
+
Authorization: "Bearer INVALID_GARBAGE_TOKEN_!@#$%^&*()"
|
|
2097
|
+
};
|
|
2098
|
+
if (sessionId) malformedHeaders["mcp-session-id"] = sessionId;
|
|
2099
|
+
try {
|
|
2100
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout);
|
|
2101
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
2102
|
+
return { passed: true, details: `HTTP ${res.statusCode} (malformed auth rejected)` };
|
|
2103
|
+
}
|
|
2104
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepted malformed auth token` };
|
|
2105
|
+
} catch (err) {
|
|
2106
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
);
|
|
2110
|
+
await test("security-tls-required", "Enforces HTTPS/TLS", "security", false, "basic/authorization", async () => {
|
|
2111
|
+
const parsedUrl = new URL(url);
|
|
2112
|
+
if (parsedUrl.protocol !== "https:") {
|
|
2113
|
+
return { passed: false, details: `Server URL uses ${parsedUrl.protocol} \u2014 production servers should use HTTPS` };
|
|
2114
|
+
}
|
|
2115
|
+
const httpUrl = url.replace(/^https:/, "http:");
|
|
2116
|
+
try {
|
|
2117
|
+
const res = await request(httpUrl, {
|
|
2118
|
+
method: "POST",
|
|
2119
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2120
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99950, method: "ping" }),
|
|
2121
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
2122
|
+
});
|
|
2123
|
+
await res.body.text();
|
|
2124
|
+
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 308) {
|
|
2125
|
+
return { passed: true, details: `HTTP ${res.statusCode} redirect to HTTPS (good)` };
|
|
2126
|
+
}
|
|
2127
|
+
if (res.statusCode >= 400) {
|
|
2128
|
+
return { passed: true, details: `HTTP ${res.statusCode} (plaintext rejected)` };
|
|
2129
|
+
}
|
|
2130
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepts plaintext HTTP connections` };
|
|
2131
|
+
} catch {
|
|
2132
|
+
return { passed: true, details: "HTTP connection refused (HTTPS enforced)" };
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
await test(
|
|
2136
|
+
"security-session-entropy",
|
|
2137
|
+
"Session IDs are high-entropy",
|
|
2138
|
+
"security",
|
|
2139
|
+
false,
|
|
2140
|
+
"basic/transports#streamable-http",
|
|
2141
|
+
async () => {
|
|
2142
|
+
if (!sessionId) {
|
|
2143
|
+
return { passed: true, details: "Server does not issue session IDs (skipped)" };
|
|
2144
|
+
}
|
|
2145
|
+
if (sessionId.length < 16) {
|
|
2146
|
+
return {
|
|
2147
|
+
passed: false,
|
|
2148
|
+
details: `Session ID too short (${sessionId.length} chars): "${sessionId}" \u2014 should be \u226516 chars`
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
if (/^\d+$/.test(sessionId)) {
|
|
2152
|
+
return {
|
|
2153
|
+
passed: false,
|
|
2154
|
+
details: `Session ID is purely numeric: "${sessionId}" \u2014 likely sequential, not random`
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
const uniqueChars = new Set(sessionId.toLowerCase()).size;
|
|
2158
|
+
if (uniqueChars < 8) {
|
|
2159
|
+
return {
|
|
2160
|
+
passed: false,
|
|
2161
|
+
details: `Session ID has low character diversity (${uniqueChars} unique chars): "${sessionId}"`
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
return {
|
|
2165
|
+
passed: true,
|
|
2166
|
+
details: `Session ID has good entropy (${sessionId.length} chars, ${uniqueChars} unique): "${sessionId.substring(0, 16)}..."`
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
);
|
|
2170
|
+
await test(
|
|
2171
|
+
"security-session-not-auth",
|
|
2172
|
+
"Session ID does not bypass auth",
|
|
2173
|
+
"security",
|
|
2174
|
+
false,
|
|
2175
|
+
"basic/transports#streamable-http",
|
|
2176
|
+
async () => {
|
|
2177
|
+
if (!hasAuth) {
|
|
2178
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2179
|
+
}
|
|
2180
|
+
if (!sessionId) {
|
|
2181
|
+
return { passed: true, details: "Skipped: server does not issue session IDs" };
|
|
2182
|
+
}
|
|
2183
|
+
const sessionOnlyHeaders = {
|
|
2184
|
+
"mcp-session-id": sessionId
|
|
2185
|
+
};
|
|
2186
|
+
try {
|
|
2187
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout);
|
|
2188
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
2189
|
+
return { passed: true, details: `HTTP ${res.statusCode} (session ID alone not sufficient for auth)` };
|
|
2190
|
+
}
|
|
2191
|
+
return {
|
|
2192
|
+
passed: false,
|
|
2193
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted session ID without auth (spec: MUST NOT use sessions for authentication)`
|
|
2194
|
+
};
|
|
2195
|
+
} catch {
|
|
2196
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
);
|
|
2200
|
+
await test(
|
|
2201
|
+
"security-oauth-metadata",
|
|
2202
|
+
"OAuth metadata endpoint exists",
|
|
2203
|
+
"security",
|
|
2204
|
+
false,
|
|
2205
|
+
"basic/authorization",
|
|
2206
|
+
async () => {
|
|
2207
|
+
if (!hasAuth) {
|
|
2208
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2209
|
+
}
|
|
2210
|
+
const parsedUrl = new URL(url);
|
|
2211
|
+
const metadataUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
|
|
2212
|
+
try {
|
|
2213
|
+
const res = await request(metadataUrl, {
|
|
2214
|
+
method: "GET",
|
|
2215
|
+
headers: { Accept: "application/json" },
|
|
2216
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
2217
|
+
});
|
|
2218
|
+
const text = await res.body.text();
|
|
2219
|
+
if (res.statusCode === 200) {
|
|
2220
|
+
try {
|
|
2221
|
+
const meta = JSON.parse(text);
|
|
2222
|
+
if (meta.issuer && meta.token_endpoint) {
|
|
2223
|
+
return { passed: true, details: `OAuth metadata found: issuer=${meta.issuer}` };
|
|
2224
|
+
}
|
|
2225
|
+
return {
|
|
2226
|
+
passed: false,
|
|
2227
|
+
details: "OAuth metadata response missing required fields (issuer, token_endpoint)"
|
|
2228
|
+
};
|
|
2229
|
+
} catch {
|
|
2230
|
+
return { passed: false, details: "OAuth metadata endpoint returned non-JSON response" };
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return { passed: false, details: `OAuth metadata endpoint returned HTTP ${res.statusCode}` };
|
|
2234
|
+
} catch {
|
|
2235
|
+
return { passed: false, details: "OAuth metadata endpoint unreachable" };
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
);
|
|
2239
|
+
await test(
|
|
2240
|
+
"security-token-in-uri",
|
|
2241
|
+
"Rejects auth tokens in query string",
|
|
2242
|
+
"security",
|
|
2243
|
+
false,
|
|
2244
|
+
"basic/authorization",
|
|
2245
|
+
async () => {
|
|
2246
|
+
if (!hasAuth) {
|
|
2247
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2248
|
+
}
|
|
2249
|
+
const authValue = userHeaders.Authorization || userHeaders.authorization || "";
|
|
2250
|
+
const token = authValue.replace(/^Bearer\s+/i, "");
|
|
2251
|
+
if (!token) {
|
|
2252
|
+
return { passed: true, details: "Skipped: could not extract token from auth header" };
|
|
2253
|
+
}
|
|
2254
|
+
const uriWithToken = `${url}${url.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
|
|
2255
|
+
try {
|
|
2256
|
+
const noAuthHeaders = {};
|
|
2257
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
2258
|
+
const res = await mcpRequest(uriWithToken, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
2259
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
2260
|
+
return { passed: true, details: `HTTP ${res.statusCode} (token in query string rejected)` };
|
|
2261
|
+
}
|
|
2262
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2263
|
+
return {
|
|
2264
|
+
passed: false,
|
|
2265
|
+
details: "Server accepted auth token in query string (spec: MUST NOT transmit credentials in URIs)"
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
return { passed: true, details: `HTTP ${res.statusCode} (token in query string not accepted)` };
|
|
2269
|
+
} catch {
|
|
2270
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
);
|
|
2274
|
+
await test(
|
|
2275
|
+
"security-cors-headers",
|
|
2276
|
+
"CORS headers are restrictive",
|
|
2277
|
+
"security",
|
|
2278
|
+
false,
|
|
2279
|
+
"basic/transports#streamable-http",
|
|
2280
|
+
async () => {
|
|
2281
|
+
try {
|
|
2282
|
+
const res = await request(backendUrl, {
|
|
2283
|
+
method: "OPTIONS",
|
|
2284
|
+
headers: {
|
|
2285
|
+
Origin: "https://evil.example.com",
|
|
2286
|
+
"Access-Control-Request-Method": "POST",
|
|
2287
|
+
...buildHeaders()
|
|
2288
|
+
},
|
|
2289
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
2290
|
+
});
|
|
2291
|
+
await res.body.text();
|
|
2292
|
+
const acao = res.headers["access-control-allow-origin"];
|
|
2293
|
+
if (!acao) {
|
|
2294
|
+
return { passed: true, details: "No CORS headers returned (server-to-server only, acceptable)" };
|
|
2295
|
+
}
|
|
2296
|
+
if (acao === "*") {
|
|
2297
|
+
return {
|
|
2298
|
+
passed: false,
|
|
2299
|
+
details: 'Access-Control-Allow-Origin is "*" (wildcard) \u2014 allows cross-origin credential theft'
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
if (acao === "https://evil.example.com") {
|
|
2303
|
+
return { passed: false, details: "Server reflects arbitrary Origin in CORS \u2014 effectively wildcard" };
|
|
2304
|
+
}
|
|
2305
|
+
return { passed: true, details: `CORS restricted to: ${acao}` };
|
|
2306
|
+
} catch {
|
|
2307
|
+
return { passed: true, details: "OPTIONS request failed (no CORS, acceptable)" };
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
);
|
|
2311
|
+
async function runInjectionTest(toolName, paramName, payloads, detectPattern, label) {
|
|
2312
|
+
const issues = [];
|
|
2313
|
+
for (const payload of payloads) {
|
|
2314
|
+
try {
|
|
2315
|
+
const res = await rpc("tools/call", { name: toolName, arguments: { [paramName]: payload } });
|
|
2316
|
+
const content = res.body?.result?.content;
|
|
2317
|
+
if (Array.isArray(content)) {
|
|
2318
|
+
const text = content.map((c) => c.text || "").join(" ");
|
|
2319
|
+
if (detectPattern.test(text)) {
|
|
2320
|
+
issues.push(`Payload "${payload}" ${label} (output: ${text.substring(0, 100)})`);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
} catch {
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2327
|
+
return {
|
|
2328
|
+
passed: true,
|
|
2329
|
+
details: `Tested ${payloads.length} payloads against ${toolName}.${paramName} \u2014 no ${label.split(" ")[0]} detected`
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
if (toolNames.length > 0) {
|
|
2333
|
+
const allTools = cachedToolsList ?? [];
|
|
2334
|
+
const toolsWithStringParams = allTools.filter((t) => {
|
|
2335
|
+
const props = t.inputSchema?.properties;
|
|
2336
|
+
if (!props) return false;
|
|
2337
|
+
return Object.values(props).some((p) => p.type === "string");
|
|
2338
|
+
});
|
|
2339
|
+
const injectionTarget = toolsWithStringParams[0] || allTools[0];
|
|
2340
|
+
const targetStringParam = injectionTarget?.inputSchema?.properties ? Object.entries(injectionTarget.inputSchema.properties).find(
|
|
2341
|
+
([_, v]) => v.type === "string"
|
|
2342
|
+
)?.[0] ?? null : null;
|
|
2343
|
+
await test(
|
|
2344
|
+
"security-command-injection",
|
|
2345
|
+
"Resists command injection in tool params",
|
|
2346
|
+
"security",
|
|
2347
|
+
false,
|
|
2348
|
+
"server/tools#calling-tools",
|
|
2349
|
+
async () => {
|
|
2350
|
+
if (!injectionTarget || !targetStringParam)
|
|
2351
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
2352
|
+
return runInjectionTest(
|
|
2353
|
+
injectionTarget.name,
|
|
2354
|
+
targetStringParam,
|
|
2355
|
+
INJECTION_PAYLOADS.command,
|
|
2356
|
+
/root:.*:\d+:\d+:.*:\/|uid=\d+\(\w+\)|drwxr|pwned/i,
|
|
2357
|
+
"appears to have executed"
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
);
|
|
2361
|
+
await test(
|
|
2362
|
+
"security-sql-injection",
|
|
2363
|
+
"Resists SQL injection in tool params",
|
|
2364
|
+
"security",
|
|
2365
|
+
false,
|
|
2366
|
+
"server/tools#calling-tools",
|
|
2367
|
+
async () => {
|
|
2368
|
+
if (!injectionTarget || !targetStringParam)
|
|
2369
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
2370
|
+
return runInjectionTest(
|
|
2371
|
+
injectionTarget.name,
|
|
2372
|
+
targetStringParam,
|
|
2373
|
+
INJECTION_PAYLOADS.sql,
|
|
2374
|
+
/syntax error|sql|mysql|postgres|sqlite|information_schema|table_name/i,
|
|
2375
|
+
"triggered database error"
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
await test(
|
|
2380
|
+
"security-path-traversal",
|
|
2381
|
+
"Resists path traversal in tool params",
|
|
2382
|
+
"security",
|
|
2383
|
+
false,
|
|
2384
|
+
"server/tools#calling-tools",
|
|
2385
|
+
async () => {
|
|
2386
|
+
if (!injectionTarget || !targetStringParam)
|
|
2387
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
2388
|
+
return runInjectionTest(
|
|
2389
|
+
injectionTarget.name,
|
|
2390
|
+
targetStringParam,
|
|
2391
|
+
INJECTION_PAYLOADS.pathTraversal,
|
|
2392
|
+
/root:.*:0:0|\[boot loader\]|\[extensions\]/i,
|
|
2393
|
+
"returned sensitive file content"
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
);
|
|
2397
|
+
await test(
|
|
2398
|
+
"security-ssrf-internal",
|
|
2399
|
+
"Resists SSRF to internal networks",
|
|
2400
|
+
"security",
|
|
2401
|
+
false,
|
|
2402
|
+
"server/tools#calling-tools",
|
|
2403
|
+
async () => {
|
|
2404
|
+
const urlParamTool = allTools.find((t) => {
|
|
2405
|
+
const props = t.inputSchema?.properties;
|
|
2406
|
+
if (!props) return false;
|
|
2407
|
+
return Object.entries(props).some(
|
|
2408
|
+
([k, v]) => v.type === "string" && /url|uri|endpoint|link|href/i.test(k)
|
|
2409
|
+
);
|
|
2410
|
+
});
|
|
2411
|
+
if (!urlParamTool) return { passed: true, details: "No tools with URL parameters found (skipped)" };
|
|
2412
|
+
const urlParam = Object.entries(urlParamTool.inputSchema.properties).find(
|
|
2413
|
+
([k, v]) => v.type === "string" && /url|uri|endpoint|link|href/i.test(k)
|
|
2414
|
+
)?.[0];
|
|
2415
|
+
if (!urlParam) return { passed: true, details: "No URL parameter found" };
|
|
2416
|
+
return runInjectionTest(
|
|
2417
|
+
urlParamTool.name,
|
|
2418
|
+
urlParam,
|
|
2419
|
+
INJECTION_PAYLOADS.ssrf,
|
|
2420
|
+
/ami-|instance-id|hostname|iam|security-credentials/i,
|
|
2421
|
+
"returned internal data"
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
);
|
|
2425
|
+
} else {
|
|
2426
|
+
for (const testId of [
|
|
2427
|
+
"security-command-injection",
|
|
2428
|
+
"security-sql-injection",
|
|
2429
|
+
"security-path-traversal",
|
|
2430
|
+
"security-ssrf-internal"
|
|
2431
|
+
]) {
|
|
2432
|
+
await test(
|
|
2433
|
+
testId,
|
|
2434
|
+
TEST_DEFINITIONS_MAP.get(testId)?.name || testId,
|
|
2435
|
+
"security",
|
|
2436
|
+
false,
|
|
2437
|
+
"server/tools#calling-tools",
|
|
2438
|
+
async () => ({ passed: true, details: "No tools available to test (skipped)" })
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
await test(
|
|
2443
|
+
"security-oversized-input",
|
|
2444
|
+
"Handles oversized inputs gracefully",
|
|
2445
|
+
"security",
|
|
2446
|
+
false,
|
|
2447
|
+
"server/tools#calling-tools",
|
|
2448
|
+
async () => {
|
|
2449
|
+
const largeValue = "A".repeat(1048576);
|
|
2450
|
+
try {
|
|
2451
|
+
const res = await request(backendUrl, {
|
|
2452
|
+
method: "POST",
|
|
2453
|
+
headers: {
|
|
2454
|
+
"Content-Type": "application/json",
|
|
2455
|
+
Accept: "application/json, text/event-stream",
|
|
2456
|
+
...buildHeaders()
|
|
2457
|
+
},
|
|
2458
|
+
body: JSON.stringify({
|
|
2459
|
+
jsonrpc: "2.0",
|
|
2460
|
+
id: nextId(),
|
|
2461
|
+
method: "tools/call",
|
|
2462
|
+
params: { name: toolNames[0] || "test", arguments: { data: largeValue } }
|
|
2463
|
+
}),
|
|
2464
|
+
signal: AbortSignal.timeout(timeout)
|
|
2465
|
+
});
|
|
2466
|
+
await res.body.text();
|
|
2467
|
+
if (res.statusCode === 413) {
|
|
2468
|
+
return { passed: true, details: "HTTP 413 Payload Too Large (good)" };
|
|
2469
|
+
}
|
|
2470
|
+
if (res.statusCode >= 400) {
|
|
2471
|
+
return { passed: true, details: `HTTP ${res.statusCode} (oversized input rejected)` };
|
|
2472
|
+
}
|
|
2473
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 server handled 1MB payload without crashing` };
|
|
2474
|
+
} catch (err) {
|
|
2475
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2476
|
+
if (msg.includes("timeout") || msg.includes("abort")) {
|
|
2477
|
+
return { passed: false, details: "Request timed out \u2014 server may be struggling with oversized input" };
|
|
2478
|
+
}
|
|
2479
|
+
return { passed: true, details: "Connection rejected (acceptable for oversized input)" };
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
);
|
|
2483
|
+
await test(
|
|
2484
|
+
"security-extra-params",
|
|
2485
|
+
"Rejects or ignores extra tool params",
|
|
2486
|
+
"security",
|
|
2487
|
+
false,
|
|
2488
|
+
"server/tools#calling-tools",
|
|
2489
|
+
async () => {
|
|
2490
|
+
if (toolNames.length === 0) {
|
|
2491
|
+
return { passed: true, details: "No tools available to test (skipped)" };
|
|
2492
|
+
}
|
|
2493
|
+
try {
|
|
2494
|
+
const res = await rpc("tools/call", {
|
|
2495
|
+
name: toolNames[0],
|
|
2496
|
+
arguments: { __injected_param__: "malicious_value", __proto__: { admin: true } }
|
|
2497
|
+
});
|
|
2498
|
+
const error = res.body?.error;
|
|
2499
|
+
if (error) {
|
|
2500
|
+
return { passed: true, details: `Extra params rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
2501
|
+
}
|
|
2502
|
+
return { passed: true, details: "Server processed request (extra params likely ignored)" };
|
|
2503
|
+
} catch {
|
|
2504
|
+
return { passed: true, details: "Request rejected (acceptable)" };
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
);
|
|
2508
|
+
await test(
|
|
2509
|
+
"security-tool-schema-defined",
|
|
2510
|
+
"All tools define inputSchema",
|
|
2511
|
+
"security",
|
|
2512
|
+
false,
|
|
2513
|
+
"server/tools#data-types",
|
|
2514
|
+
async () => {
|
|
2515
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
2516
|
+
const tools = cachedToolsList ?? [];
|
|
2517
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
2518
|
+
const missing = tools.filter((t) => !t.inputSchema || t.inputSchema.type !== "object");
|
|
2519
|
+
if (missing.length > 0) {
|
|
2520
|
+
return {
|
|
2521
|
+
passed: false,
|
|
2522
|
+
details: `${missing.length} tool(s) missing inputSchema: ${missing.map((t) => t.name).join(", ")}`
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
return { passed: true, details: `All ${tools.length} tool(s) have inputSchema defined` };
|
|
2526
|
+
}
|
|
2527
|
+
);
|
|
2528
|
+
await test(
|
|
2529
|
+
"security-tool-rug-pull",
|
|
2530
|
+
"Tool definitions are stable across calls",
|
|
2531
|
+
"security",
|
|
2532
|
+
false,
|
|
2533
|
+
"server/tools#listing-tools",
|
|
2534
|
+
async () => {
|
|
2535
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
2536
|
+
try {
|
|
2537
|
+
const res = await rpc("tools/list");
|
|
2538
|
+
const tools2 = res.body?.result?.tools;
|
|
2539
|
+
if (!Array.isArray(tools2)) return { passed: false, details: "Second tools/list call failed" };
|
|
2540
|
+
const tools1 = cachedToolsList ?? [];
|
|
2541
|
+
if (tools1.length !== tools2.length) {
|
|
2542
|
+
return {
|
|
2543
|
+
passed: false,
|
|
2544
|
+
details: `Tool count changed: ${tools1.length} \u2192 ${tools2.length} (possible rug-pull)`
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
const names1 = tools1.map((t) => t.name).sort().join(",");
|
|
2548
|
+
const names2 = tools2.map((t) => t.name).sort().join(",");
|
|
2549
|
+
if (names1 !== names2) {
|
|
2550
|
+
return { passed: false, details: "Tool names changed between calls (possible rug-pull)" };
|
|
2551
|
+
}
|
|
2552
|
+
for (const t1 of tools1) {
|
|
2553
|
+
const t2 = tools2.find((t) => t.name === t1.name);
|
|
2554
|
+
if (t2 && t1.description !== t2.description) {
|
|
2555
|
+
return {
|
|
2556
|
+
passed: false,
|
|
2557
|
+
details: `Tool "${t1.name}" description changed between calls (possible rug-pull)`
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
return { passed: true, details: `${tools1.length} tool(s) consistent across 2 calls` };
|
|
2562
|
+
} catch {
|
|
2563
|
+
return { passed: false, details: "Second tools/list call threw an error" };
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
);
|
|
2567
|
+
await test(
|
|
2568
|
+
"security-tool-description-poisoning",
|
|
2569
|
+
"Tool descriptions free of injection patterns",
|
|
2570
|
+
"security",
|
|
2571
|
+
false,
|
|
2572
|
+
"server/tools#data-types",
|
|
2573
|
+
async () => {
|
|
2574
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
2575
|
+
const tools = cachedToolsList ?? [];
|
|
2576
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
2577
|
+
const suspiciousPatterns = [
|
|
2578
|
+
{ pattern: /ignore\s+(all\s+)?previous/i, label: "ignore previous instructions" },
|
|
2579
|
+
{ pattern: /override\s+(system|instructions|rules)/i, label: "override instructions" },
|
|
2580
|
+
{ pattern: /system\s+prompt/i, label: "system prompt reference" },
|
|
2581
|
+
{ pattern: /you\s+must\s+(always|never)/i, label: "behavioral override" },
|
|
2582
|
+
{ pattern: /do\s+not\s+(tell|inform|mention)/i, label: "concealment instruction" },
|
|
2583
|
+
{ pattern: /\b[A-Za-z0-9+/]{40,}={0,2}\b/, label: "possible Base64-encoded payload" },
|
|
2584
|
+
// Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
|
|
2585
|
+
{ pattern: /\u200B|\u200C|\u200D|\uFEFF/, label: "hidden Unicode characters" }
|
|
2586
|
+
];
|
|
2587
|
+
const issues = [];
|
|
2588
|
+
for (const tool of tools) {
|
|
2589
|
+
const textsToCheck = [
|
|
2590
|
+
tool.description || "",
|
|
2591
|
+
...tool.inputSchema?.properties ? Object.values(tool.inputSchema.properties).map((p) => p.description || "") : []
|
|
2592
|
+
];
|
|
2593
|
+
const combined = textsToCheck.join(" ");
|
|
2594
|
+
for (const { pattern, label } of suspiciousPatterns) {
|
|
2595
|
+
if (pattern.test(combined)) {
|
|
2596
|
+
issues.push(`Tool "${tool.name}": ${label}`);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2601
|
+
return { passed: true, details: `${tools.length} tool(s) scanned \u2014 no injection patterns found` };
|
|
2602
|
+
}
|
|
2603
|
+
);
|
|
2604
|
+
await test(
|
|
2605
|
+
"security-tool-cross-reference",
|
|
2606
|
+
"Tools do not reference other tools by name",
|
|
2607
|
+
"security",
|
|
2608
|
+
false,
|
|
2609
|
+
"server/tools#data-types",
|
|
2610
|
+
async () => {
|
|
2611
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
2612
|
+
const tools = cachedToolsList ?? [];
|
|
2613
|
+
if (tools.length < 2)
|
|
2614
|
+
return { passed: true, details: "Fewer than 2 tools \u2014 cross-reference check not applicable" };
|
|
2615
|
+
const names = tools.map((t) => t.name).filter(Boolean);
|
|
2616
|
+
const issues = [];
|
|
2617
|
+
for (const tool of tools) {
|
|
2618
|
+
const desc = (tool.description || "").toLowerCase();
|
|
2619
|
+
for (const otherName of names) {
|
|
2620
|
+
if (otherName === tool.name) continue;
|
|
2621
|
+
if (desc.includes(otherName.toLowerCase())) {
|
|
2622
|
+
issues.push(`Tool "${tool.name}" description references "${otherName}"`);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if (issues.length > 0) {
|
|
2627
|
+
warnings.push(`Cross-tool references found: ${issues.join("; ")}`);
|
|
2628
|
+
return { passed: false, details: issues.join("; ") };
|
|
2629
|
+
}
|
|
2630
|
+
return { passed: true, details: `${tools.length} tool(s) checked \u2014 no cross-references found` };
|
|
2631
|
+
}
|
|
2632
|
+
);
|
|
2633
|
+
await test(
|
|
2634
|
+
"security-error-no-stacktrace",
|
|
2635
|
+
"Error responses do not leak stack traces",
|
|
2636
|
+
"security",
|
|
2637
|
+
false,
|
|
2638
|
+
"basic",
|
|
2639
|
+
async () => {
|
|
2640
|
+
const errorResponses = [];
|
|
2641
|
+
const errorPayloads = [
|
|
2642
|
+
"{this is not valid json!!!",
|
|
2643
|
+
JSON.stringify({ jsonrpc: "2.0", id: nextId(), method: "nonexistent/___crash___test___" }),
|
|
2644
|
+
JSON.stringify({
|
|
2645
|
+
jsonrpc: "2.0",
|
|
2646
|
+
id: nextId(),
|
|
2647
|
+
method: "tools/call",
|
|
2648
|
+
params: { name: "___nonexistent___tool___" }
|
|
2649
|
+
})
|
|
2650
|
+
];
|
|
2651
|
+
for (const payload of errorPayloads) {
|
|
2652
|
+
try {
|
|
2653
|
+
const res = await request(backendUrl, {
|
|
2654
|
+
method: "POST",
|
|
2655
|
+
headers: {
|
|
2656
|
+
"Content-Type": "application/json",
|
|
2657
|
+
Accept: "application/json, text/event-stream",
|
|
2658
|
+
...buildHeaders()
|
|
2659
|
+
},
|
|
2660
|
+
body: payload,
|
|
2661
|
+
signal: AbortSignal.timeout(timeout)
|
|
2662
|
+
});
|
|
2663
|
+
const text = await res.body.text();
|
|
2664
|
+
errorResponses.push(text);
|
|
2665
|
+
} catch {
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
const issues = [];
|
|
2669
|
+
for (const text of errorResponses) {
|
|
2670
|
+
for (const pattern of STACK_TRACE_PATTERNS) {
|
|
2671
|
+
if (pattern.test(text)) {
|
|
2672
|
+
issues.push(`Response contains: ${pattern.source} (matched in: ${text.substring(0, 80)}...)`);
|
|
2673
|
+
break;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
if (issues.length > 0) return { passed: false, details: issues.slice(0, 3).join("; ") };
|
|
2678
|
+
return {
|
|
2679
|
+
passed: true,
|
|
2680
|
+
details: `${errorResponses.length} error responses checked \u2014 no stack traces or sensitive data found`
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
);
|
|
2684
|
+
await test(
|
|
2685
|
+
"security-error-no-internal-ip",
|
|
2686
|
+
"Error responses do not leak internal IPs",
|
|
2687
|
+
"security",
|
|
2688
|
+
false,
|
|
2689
|
+
"basic",
|
|
2690
|
+
async () => {
|
|
2691
|
+
try {
|
|
2692
|
+
const res = await request(backendUrl, {
|
|
2693
|
+
method: "POST",
|
|
2694
|
+
headers: {
|
|
2695
|
+
"Content-Type": "application/json",
|
|
2696
|
+
Accept: "application/json, text/event-stream",
|
|
2697
|
+
...buildHeaders()
|
|
2698
|
+
},
|
|
2699
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: nextId(), method: "___trigger_error___" }),
|
|
2700
|
+
signal: AbortSignal.timeout(timeout)
|
|
2701
|
+
});
|
|
2702
|
+
const text = await res.body.text();
|
|
2703
|
+
for (const pattern of INTERNAL_IP_PATTERNS) {
|
|
2704
|
+
const match = text.match(pattern);
|
|
2705
|
+
if (match) {
|
|
2706
|
+
return { passed: false, details: `Error response contains internal IP: ${match[0]}` };
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return { passed: true, details: "No internal IP addresses found in error responses" };
|
|
2710
|
+
} catch {
|
|
2711
|
+
return { passed: true, details: "No response to check (connection error)" };
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
);
|
|
2715
|
+
await test(
|
|
2716
|
+
"security-rate-limiting",
|
|
2717
|
+
"Rate limiting is enforced",
|
|
2718
|
+
"security",
|
|
2719
|
+
false,
|
|
2720
|
+
"basic/transports#streamable-http",
|
|
2721
|
+
async () => {
|
|
2722
|
+
const burstSize = 50;
|
|
2723
|
+
let got429 = false;
|
|
2724
|
+
const promises = Array.from(
|
|
2725
|
+
{ length: burstSize },
|
|
2726
|
+
() => mcpRequest(backendUrl, "ping", void 0, nextId, buildHeaders(), timeout).then((res) => {
|
|
2727
|
+
if (res.statusCode === 429) got429 = true;
|
|
2728
|
+
return res.statusCode;
|
|
2729
|
+
}).catch(() => 0)
|
|
2730
|
+
);
|
|
2731
|
+
const statusCodes = await Promise.all(promises);
|
|
2732
|
+
if (got429) {
|
|
2733
|
+
return { passed: true, details: `Rate limiting detected (429 returned after ${burstSize} rapid requests)` };
|
|
2734
|
+
}
|
|
2735
|
+
const errorCount = statusCodes.filter((c) => c >= 500).length;
|
|
2736
|
+
if (errorCount > burstSize / 2) {
|
|
2737
|
+
return {
|
|
2738
|
+
passed: false,
|
|
2739
|
+
details: `Server returned ${errorCount}/${burstSize} 5xx errors under load \u2014 should return 429 instead of crashing`
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
return {
|
|
2743
|
+
passed: false,
|
|
2744
|
+
details: `No rate limiting detected (${burstSize} rapid requests all returned ${[...new Set(statusCodes)].join(",")})`
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
);
|
|
2748
|
+
await test(
|
|
2749
|
+
"transport-delete",
|
|
2750
|
+
"DELETE accepted or returns 405",
|
|
2751
|
+
"transport",
|
|
2752
|
+
false,
|
|
2753
|
+
"basic/transports#streamable-http",
|
|
2754
|
+
async () => {
|
|
2755
|
+
const deleteHeaders = { ...buildHeaders() };
|
|
2756
|
+
const res = await request(backendUrl, {
|
|
2757
|
+
method: "DELETE",
|
|
2758
|
+
headers: deleteHeaders,
|
|
2759
|
+
signal: AbortSignal.timeout(timeout)
|
|
2760
|
+
});
|
|
2761
|
+
await res.body.text();
|
|
2762
|
+
if (res.statusCode === 405) {
|
|
2763
|
+
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
2764
|
+
}
|
|
2765
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2766
|
+
if (sessionId) {
|
|
2767
|
+
try {
|
|
2768
|
+
const verifyRes = await mcpRequest(
|
|
2769
|
+
backendUrl,
|
|
2770
|
+
"ping",
|
|
2771
|
+
void 0,
|
|
2772
|
+
createIdCounter(99920),
|
|
2773
|
+
deleteHeaders,
|
|
2774
|
+
timeout
|
|
2775
|
+
);
|
|
2776
|
+
if (verifyRes.statusCode === 400 || verifyRes.statusCode === 404 || verifyRes.statusCode === 409) {
|
|
2777
|
+
return {
|
|
2778
|
+
passed: true,
|
|
2779
|
+
details: `HTTP ${res.statusCode} (session terminated, post-delete request correctly rejected with ${verifyRes.statusCode})`
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
} catch {
|
|
2783
|
+
return {
|
|
2784
|
+
passed: true,
|
|
2785
|
+
details: `HTTP ${res.statusCode} (session terminated, post-delete request rejected)`
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
return { passed: true, details: `HTTP ${res.statusCode} (session termination supported)` };
|
|
2790
|
+
}
|
|
2791
|
+
if (res.statusCode === 400 || res.statusCode === 404) {
|
|
2792
|
+
return { passed: true, details: `HTTP ${res.statusCode} (no active session, acceptable)` };
|
|
2793
|
+
}
|
|
2794
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
2795
|
+
}
|
|
2796
|
+
);
|
|
2797
|
+
const MAX_WARNINGS = 50;
|
|
2798
|
+
if (warnings.length > MAX_WARNINGS) {
|
|
2799
|
+
const truncated = warnings.length - MAX_WARNINGS;
|
|
2800
|
+
warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
|
|
2801
|
+
}
|
|
1590
2802
|
const { score, grade, overall, summary, categories } = computeScore(tests);
|
|
1591
2803
|
const badge = generateBadge(url);
|
|
1592
2804
|
return {
|
|
@@ -1617,5 +2829,8 @@ export {
|
|
|
1617
2829
|
computeGrade,
|
|
1618
2830
|
computeScore,
|
|
1619
2831
|
TEST_DEFINITIONS,
|
|
2832
|
+
SPEC_VERSION,
|
|
2833
|
+
SPEC_BASE,
|
|
2834
|
+
parseSSEResponse,
|
|
1620
2835
|
runComplianceSuite
|
|
1621
2836
|
};
|