@yawlabs/mcp-compliance 0.9.0 → 0.9.2
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 +1 -1
- package/dist/chunk-FKTEFLK5.js +3914 -0
- package/dist/index.js +2462 -2277
- package/dist/mcp/server.js +1 -1
- package/dist/runner.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-T26GLXNK.js +0 -3789
|
@@ -0,0 +1,3914 @@
|
|
|
1
|
+
// src/runner.ts
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { request as request2 } from "undici";
|
|
4
|
+
|
|
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
|
+
}
|
|
10
|
+
function generateBadge(url) {
|
|
11
|
+
const hash = urlHash(url);
|
|
12
|
+
const imageUrl = `https://mcp.hosting/api/compliance/ext/${hash}/badge`;
|
|
13
|
+
const reportUrl = `https://mcp.hosting/compliance/ext/${hash}`;
|
|
14
|
+
return {
|
|
15
|
+
imageUrl,
|
|
16
|
+
reportUrl,
|
|
17
|
+
markdown: `[](${reportUrl})`,
|
|
18
|
+
html: `<a href="${reportUrl}"><img src="${imageUrl}" alt="MCP Compliant"></a>`
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/grader.ts
|
|
23
|
+
function computeGrade(score) {
|
|
24
|
+
if (score >= 90) return "A";
|
|
25
|
+
if (score >= 75) return "B";
|
|
26
|
+
if (score >= 60) return "C";
|
|
27
|
+
if (score >= 40) return "D";
|
|
28
|
+
return "F";
|
|
29
|
+
}
|
|
30
|
+
function computeScore(tests) {
|
|
31
|
+
const total = tests.length;
|
|
32
|
+
const passed = tests.filter((t) => t.passed).length;
|
|
33
|
+
const failed = total - passed;
|
|
34
|
+
const requiredTests = tests.filter((t) => t.required);
|
|
35
|
+
const requiredPassed = requiredTests.filter((t) => t.passed).length;
|
|
36
|
+
const requiredScore = requiredTests.length > 0 ? requiredPassed / requiredTests.length * 70 : 70;
|
|
37
|
+
const optionalTests = tests.filter((t) => !t.required);
|
|
38
|
+
const optionalPassed = optionalTests.filter((t) => t.passed).length;
|
|
39
|
+
const optionalScore = optionalTests.length > 0 ? optionalPassed / optionalTests.length * 30 : 30;
|
|
40
|
+
const score = Math.round(requiredScore + optionalScore);
|
|
41
|
+
const overall = requiredPassed === requiredTests.length ? passed === total ? "pass" : "partial" : "fail";
|
|
42
|
+
const categories = {};
|
|
43
|
+
for (const t of tests) {
|
|
44
|
+
if (!categories[t.category]) categories[t.category] = { passed: 0, total: 0 };
|
|
45
|
+
categories[t.category].total++;
|
|
46
|
+
if (t.passed) categories[t.category].passed++;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
score,
|
|
50
|
+
grade: computeGrade(score),
|
|
51
|
+
overall,
|
|
52
|
+
summary: { total, passed, failed, required: requiredTests.length, requiredPassed },
|
|
53
|
+
categories
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/transport/http.ts
|
|
58
|
+
import { request } from "undici";
|
|
59
|
+
|
|
60
|
+
// src/sse.ts
|
|
61
|
+
function parseSSEResponse(text) {
|
|
62
|
+
const lines = text.split("\n");
|
|
63
|
+
let firstJsonRpcResponse = null;
|
|
64
|
+
let currentData = [];
|
|
65
|
+
function flushEvent() {
|
|
66
|
+
if (currentData.length === 0) return;
|
|
67
|
+
const data = currentData.join("\n");
|
|
68
|
+
currentData = [];
|
|
69
|
+
if (!data.trim()) return;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(data);
|
|
72
|
+
if (!firstJsonRpcResponse && parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
|
|
73
|
+
firstJsonRpcResponse = parsed;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.startsWith("data:")) {
|
|
80
|
+
const content = line.slice(5);
|
|
81
|
+
currentData.push(content.startsWith(" ") ? content.slice(1) : content);
|
|
82
|
+
} else if (line.trim() === "") {
|
|
83
|
+
flushEvent();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
flushEvent();
|
|
87
|
+
return firstJsonRpcResponse;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/transport/http.ts
|
|
91
|
+
function createHttpTransport(opts) {
|
|
92
|
+
const { url } = opts;
|
|
93
|
+
const userHeaders = { ...opts.headers ?? {} };
|
|
94
|
+
let sessionId = null;
|
|
95
|
+
let protocolVersion = null;
|
|
96
|
+
function sessionHeaders() {
|
|
97
|
+
const h = { ...userHeaders };
|
|
98
|
+
if (sessionId) h["mcp-session-id"] = sessionId;
|
|
99
|
+
if (protocolVersion) h["mcp-protocol-version"] = protocolVersion;
|
|
100
|
+
return h;
|
|
101
|
+
}
|
|
102
|
+
function normalizeHeaders(raw) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
105
|
+
if (typeof v === "string") out[k] = v;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
async function doRawRequest(method, body, extraHeaders, timeout) {
|
|
110
|
+
const headers = {
|
|
111
|
+
Accept: "application/json, text/event-stream",
|
|
112
|
+
...sessionHeaders(),
|
|
113
|
+
...extraHeaders
|
|
114
|
+
};
|
|
115
|
+
if (body !== void 0 && !("Content-Type" in headers) && !("content-type" in headers)) {
|
|
116
|
+
headers["Content-Type"] = "application/json";
|
|
117
|
+
}
|
|
118
|
+
const res = await request(url, {
|
|
119
|
+
method,
|
|
120
|
+
headers,
|
|
121
|
+
body,
|
|
122
|
+
signal: AbortSignal.timeout(timeout)
|
|
123
|
+
});
|
|
124
|
+
const text = await res.body.text();
|
|
125
|
+
return {
|
|
126
|
+
statusCode: res.statusCode,
|
|
127
|
+
body: text,
|
|
128
|
+
headers: normalizeHeaders(res.headers)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const transport = {
|
|
132
|
+
kind: "http",
|
|
133
|
+
url,
|
|
134
|
+
async request(method, params, nextId, init) {
|
|
135
|
+
const id = nextId();
|
|
136
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
137
|
+
const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
|
|
138
|
+
const contentType = (raw.headers["content-type"] || "").toLowerCase();
|
|
139
|
+
let parsed;
|
|
140
|
+
if (contentType.includes("text/event-stream")) {
|
|
141
|
+
const sseParsed = parseSSEResponse(raw.body);
|
|
142
|
+
if (sseParsed) {
|
|
143
|
+
parsed = sseParsed;
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw.body);
|
|
147
|
+
} catch {
|
|
148
|
+
parsed = { _raw: raw.body };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(raw.body);
|
|
154
|
+
} catch {
|
|
155
|
+
parsed = { _raw: raw.body };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
body: parsed,
|
|
160
|
+
requestId: id,
|
|
161
|
+
statusCode: raw.statusCode,
|
|
162
|
+
headers: raw.headers
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
async notify(method, params, init) {
|
|
166
|
+
const body = JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} });
|
|
167
|
+
const raw = await doRawRequest("POST", body, init.headers ?? {}, init.timeout);
|
|
168
|
+
return { statusCode: raw.statusCode, headers: raw.headers };
|
|
169
|
+
},
|
|
170
|
+
async close() {
|
|
171
|
+
},
|
|
172
|
+
setSessionId(id) {
|
|
173
|
+
sessionId = id;
|
|
174
|
+
},
|
|
175
|
+
setProtocolVersion(v) {
|
|
176
|
+
protocolVersion = v;
|
|
177
|
+
},
|
|
178
|
+
getSessionId() {
|
|
179
|
+
return sessionId;
|
|
180
|
+
},
|
|
181
|
+
getProtocolVersion() {
|
|
182
|
+
return protocolVersion;
|
|
183
|
+
},
|
|
184
|
+
rawPost(body, extraHeaders, timeout) {
|
|
185
|
+
return doRawRequest("POST", body, extraHeaders, timeout);
|
|
186
|
+
},
|
|
187
|
+
rawRequest(method, body, extraHeaders, timeout) {
|
|
188
|
+
return doRawRequest(method, body, extraHeaders, timeout);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
return transport;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/transport/stdio.ts
|
|
195
|
+
import { spawn } from "child_process";
|
|
196
|
+
function createStdioTransport(opts) {
|
|
197
|
+
const { command, args = [], env, cwd, verbose = false } = opts;
|
|
198
|
+
const stderrBufferSize = opts.stderrBufferSize ?? 64 * 1024;
|
|
199
|
+
const stdoutBufferSize = opts.stdoutBufferSize ?? 1024 * 1024;
|
|
200
|
+
const isWindows = process.platform === "win32";
|
|
201
|
+
const child = spawn(command, args, {
|
|
202
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
203
|
+
cwd,
|
|
204
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
205
|
+
// Windows .cmd/.bat shims (npx, npm) need shell:true to launch.
|
|
206
|
+
shell: isWindows
|
|
207
|
+
});
|
|
208
|
+
let protocolVersion = null;
|
|
209
|
+
let exited = false;
|
|
210
|
+
let exitCode = null;
|
|
211
|
+
let spawnError = null;
|
|
212
|
+
const pending = /* @__PURE__ */ new Map();
|
|
213
|
+
let stdoutBuffer = "";
|
|
214
|
+
let stderrBuffer = "";
|
|
215
|
+
child.on("error", (err) => {
|
|
216
|
+
spawnError = err;
|
|
217
|
+
rejectAllPending(err);
|
|
218
|
+
});
|
|
219
|
+
child.on("exit", (code, signal) => {
|
|
220
|
+
exited = true;
|
|
221
|
+
exitCode = code;
|
|
222
|
+
if (pending.size > 0) {
|
|
223
|
+
const reason = signal ? `child exited (signal ${signal})` : `child exited with code ${code}`;
|
|
224
|
+
rejectAllPending(new Error(reason));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
child.stdout?.setEncoding("utf8");
|
|
228
|
+
child.stdout?.on("data", (chunk) => {
|
|
229
|
+
stdoutBuffer += chunk;
|
|
230
|
+
let idx;
|
|
231
|
+
while ((idx = stdoutBuffer.indexOf("\n")) !== -1) {
|
|
232
|
+
const line = stdoutBuffer.slice(0, idx);
|
|
233
|
+
stdoutBuffer = stdoutBuffer.slice(idx + 1);
|
|
234
|
+
handleLine(line);
|
|
235
|
+
}
|
|
236
|
+
if (stdoutBuffer.length > stdoutBufferSize) {
|
|
237
|
+
stdoutBuffer = "";
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
child.stderr?.setEncoding("utf8");
|
|
241
|
+
child.stderr?.on("data", (chunk) => {
|
|
242
|
+
if (verbose) process.stderr.write(chunk);
|
|
243
|
+
stderrBuffer += chunk;
|
|
244
|
+
if (stderrBuffer.length > stderrBufferSize) {
|
|
245
|
+
stderrBuffer = stderrBuffer.slice(stderrBuffer.length - stderrBufferSize);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
function handleLine(line) {
|
|
249
|
+
const trimmed = line.trim();
|
|
250
|
+
if (!trimmed) return;
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(trimmed);
|
|
254
|
+
} catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
258
|
+
const msg = parsed;
|
|
259
|
+
if (typeof msg.id === "number" && pending.has(msg.id)) {
|
|
260
|
+
const p = pending.get(msg.id);
|
|
261
|
+
if (!p) return;
|
|
262
|
+
clearTimeout(p.timer);
|
|
263
|
+
pending.delete(msg.id);
|
|
264
|
+
p.resolve({ body: parsed, requestId: msg.id });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function rejectAllPending(err) {
|
|
268
|
+
const annotated = err.message.includes("child stderr") ? err : new Error(annotateWithStderr(err.message));
|
|
269
|
+
for (const p of pending.values()) {
|
|
270
|
+
clearTimeout(p.timer);
|
|
271
|
+
p.reject(annotated);
|
|
272
|
+
}
|
|
273
|
+
pending.clear();
|
|
274
|
+
}
|
|
275
|
+
function annotateWithStderr(message) {
|
|
276
|
+
const tail = stderrBuffer.trim();
|
|
277
|
+
if (!tail) return message;
|
|
278
|
+
const snippet = tail.length > 800 ? `\u2026${tail.slice(-800)}` : tail;
|
|
279
|
+
return `${message}
|
|
280
|
+
child stderr:
|
|
281
|
+
${snippet.replace(/\n/g, "\n ")}`;
|
|
282
|
+
}
|
|
283
|
+
async function writeLine(line) {
|
|
284
|
+
if (exited) {
|
|
285
|
+
throw new Error(annotateWithStderr(`stdio transport: child has exited (code ${exitCode})`));
|
|
286
|
+
}
|
|
287
|
+
if (spawnError) throw new Error(annotateWithStderr(`stdio transport: spawn failed \u2014 ${spawnError.message}`));
|
|
288
|
+
const stdin = child.stdin;
|
|
289
|
+
if (!stdin || stdin.destroyed) throw new Error(annotateWithStderr("stdio transport: stdin is closed"));
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
stdin.write(`${line}
|
|
292
|
+
`, "utf8", (err) => err ? reject(err) : resolve());
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const transport = {
|
|
296
|
+
kind: "stdio",
|
|
297
|
+
command,
|
|
298
|
+
args,
|
|
299
|
+
get pid() {
|
|
300
|
+
return child.pid;
|
|
301
|
+
},
|
|
302
|
+
get exited() {
|
|
303
|
+
return exited;
|
|
304
|
+
},
|
|
305
|
+
get exitCode() {
|
|
306
|
+
return exitCode;
|
|
307
|
+
},
|
|
308
|
+
stderrTail() {
|
|
309
|
+
return stderrBuffer;
|
|
310
|
+
},
|
|
311
|
+
async request(method, params, nextId, init) {
|
|
312
|
+
const id = nextId();
|
|
313
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
pending.delete(id);
|
|
317
|
+
reject(
|
|
318
|
+
new Error(
|
|
319
|
+
annotateWithStderr(`stdio transport: request timed out after ${init.timeout}ms (method=${method})`)
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
}, init.timeout);
|
|
323
|
+
pending.set(id, { resolve, reject, id, timer });
|
|
324
|
+
writeLine(body).catch((err) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
pending.delete(id);
|
|
327
|
+
reject(err);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
async notify(method, params, _init) {
|
|
332
|
+
const body = JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} });
|
|
333
|
+
await writeLine(body);
|
|
334
|
+
return {};
|
|
335
|
+
},
|
|
336
|
+
async close() {
|
|
337
|
+
if (exited) return;
|
|
338
|
+
try {
|
|
339
|
+
child.stdin?.end();
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
const gracePeriodMs = 2e3;
|
|
343
|
+
await new Promise((resolve) => {
|
|
344
|
+
const timer = setTimeout(() => {
|
|
345
|
+
try {
|
|
346
|
+
child.kill("SIGKILL");
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
resolve();
|
|
350
|
+
}, gracePeriodMs);
|
|
351
|
+
child.once("exit", () => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
resolve();
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
child.kill(isWindows ? void 0 : "SIGTERM");
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
rejectAllPending(new Error("stdio transport: closed"));
|
|
361
|
+
},
|
|
362
|
+
setSessionId(_id) {
|
|
363
|
+
},
|
|
364
|
+
setProtocolVersion(v) {
|
|
365
|
+
protocolVersion = v;
|
|
366
|
+
},
|
|
367
|
+
getSessionId() {
|
|
368
|
+
return null;
|
|
369
|
+
},
|
|
370
|
+
getProtocolVersion() {
|
|
371
|
+
return protocolVersion;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
return transport;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/types.ts
|
|
378
|
+
var TEST_DEFINITIONS = [
|
|
379
|
+
// ── Transport (13 tests) ─────────────────────────────────────────
|
|
380
|
+
{
|
|
381
|
+
id: "transport-post",
|
|
382
|
+
name: "HTTP POST accepted",
|
|
383
|
+
category: "transport",
|
|
384
|
+
required: true,
|
|
385
|
+
specRef: "basic/transports#streamable-http",
|
|
386
|
+
description: "Verifies the server accepts HTTP POST requests and returns a 2xx status code. This is the fundamental transport requirement for Streamable HTTP MCP servers.",
|
|
387
|
+
recommendation: "Ensure your server listens for POST requests on the MCP endpoint. If you see 401/403, pass --auth with a valid token. Check that the URL is correct and the server is running."
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "transport-content-type",
|
|
391
|
+
name: "Responds with JSON or SSE",
|
|
392
|
+
category: "transport",
|
|
393
|
+
required: true,
|
|
394
|
+
specRef: "basic/transports#streamable-http",
|
|
395
|
+
description: "Checks that the server responds with Content-Type application/json or text/event-stream. MCP servers must use one of these two content types.",
|
|
396
|
+
recommendation: 'Set the Content-Type response header to "application/json" for synchronous responses or "text/event-stream" for streaming. Do not use text/html or other types.'
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: "transport-notification-202",
|
|
400
|
+
name: "Notification returns 202 Accepted",
|
|
401
|
+
category: "transport",
|
|
402
|
+
required: false,
|
|
403
|
+
specRef: "basic/transports#streamable-http",
|
|
404
|
+
description: "Verifies that sending a JSON-RPC notification (no id field) returns exactly HTTP 202 Accepted with no body. Per spec, servers MUST return 202 \u2014 not 200 or 204.",
|
|
405
|
+
recommendation: "Detect JSON-RPC messages without an id field and return HTTP 202 with an empty body. Do not return 200 or 204 \u2014 the spec requires exactly 202 Accepted."
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "transport-session-id",
|
|
409
|
+
name: "Enforces MCP-Session-Id after init",
|
|
410
|
+
category: "transport",
|
|
411
|
+
required: false,
|
|
412
|
+
specRef: "basic/transports#streamable-http",
|
|
413
|
+
description: "Tests that the server returns HTTP 400 when MCP-Session-Id header is missing on requests after initialization (when the server issued a session ID).",
|
|
414
|
+
recommendation: "If your server issues an MCP-Session-Id header in the initialize response, reject subsequent requests that omit this header with HTTP 400."
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: "transport-session-invalid",
|
|
418
|
+
name: "Returns 404 for unknown session ID",
|
|
419
|
+
category: "transport",
|
|
420
|
+
required: false,
|
|
421
|
+
specRef: "basic/transports#streamable-http",
|
|
422
|
+
description: "Sends a request with a fabricated MCP-Session-Id and verifies the server returns HTTP 404. Per spec, servers managing sessions MUST return 404 for unrecognized session IDs.",
|
|
423
|
+
recommendation: "Return HTTP 404 (Not Found) for requests with an MCP-Session-Id that does not match any active session. Do not return 400 \u2014 that is for missing session IDs."
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "transport-content-type-reject",
|
|
427
|
+
name: "Rejects non-JSON request Content-Type",
|
|
428
|
+
category: "transport",
|
|
429
|
+
required: false,
|
|
430
|
+
specRef: "basic/transports#streamable-http",
|
|
431
|
+
description: "Sends a POST with Content-Type: text/plain instead of application/json and verifies the server rejects it with a 4xx status.",
|
|
432
|
+
recommendation: "Validate the Content-Type header on incoming POST requests. Reject requests that are not application/json with HTTP 415 (Unsupported Media Type) or 400."
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: "transport-get",
|
|
436
|
+
name: "GET returns SSE stream or 405",
|
|
437
|
+
category: "transport",
|
|
438
|
+
required: false,
|
|
439
|
+
specRef: "basic/transports#streamable-http",
|
|
440
|
+
description: "Tests the GET endpoint for server-initiated messages. Server should return text/event-stream or 405 Method Not Allowed.",
|
|
441
|
+
recommendation: "If your server supports server-initiated messages, handle GET with text/event-stream. Otherwise, return 405 Method Not Allowed."
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: "transport-delete",
|
|
445
|
+
name: "DELETE accepted or returns 405",
|
|
446
|
+
category: "transport",
|
|
447
|
+
required: false,
|
|
448
|
+
specRef: "basic/transports#streamable-http",
|
|
449
|
+
description: "Tests the DELETE endpoint for session termination. Server should accept the request or return 405 Method Not Allowed.",
|
|
450
|
+
recommendation: "Handle DELETE requests for session cleanup, or return 405 if session termination is not supported. Do not return 500."
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "transport-batch-reject",
|
|
454
|
+
name: "Rejects JSON-RPC batch requests",
|
|
455
|
+
category: "transport",
|
|
456
|
+
required: true,
|
|
457
|
+
specRef: "basic/transports#streamable-http",
|
|
458
|
+
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.",
|
|
459
|
+
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."
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "transport-content-type-init",
|
|
463
|
+
name: "Initialize response has valid content type",
|
|
464
|
+
category: "transport",
|
|
465
|
+
required: false,
|
|
466
|
+
specRef: "basic/transports#streamable-http",
|
|
467
|
+
description: "Validates that the initialize response uses application/json or text/event-stream content type. Some servers return other types for the handshake.",
|
|
468
|
+
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.'
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "transport-get-stream",
|
|
472
|
+
name: "GET with session returns SSE or 405",
|
|
473
|
+
category: "transport",
|
|
474
|
+
required: false,
|
|
475
|
+
specRef: "basic/transports#streamable-http",
|
|
476
|
+
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.",
|
|
477
|
+
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."
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
id: "transport-concurrent",
|
|
481
|
+
name: "Handles concurrent requests",
|
|
482
|
+
category: "transport",
|
|
483
|
+
required: false,
|
|
484
|
+
specRef: "basic/transports#streamable-http",
|
|
485
|
+
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.",
|
|
486
|
+
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."
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
id: "transport-sse-event-field",
|
|
490
|
+
name: "SSE responses include event: message",
|
|
491
|
+
category: "transport",
|
|
492
|
+
required: false,
|
|
493
|
+
specRef: "basic/transports#streamable-http",
|
|
494
|
+
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.",
|
|
495
|
+
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.'
|
|
496
|
+
},
|
|
497
|
+
// ── Stdio transport (stdio-only) ─────────────────────────────────
|
|
498
|
+
{
|
|
499
|
+
id: "stdio-framing",
|
|
500
|
+
name: "Newline-delimited JSON framing",
|
|
501
|
+
category: "transport",
|
|
502
|
+
required: true,
|
|
503
|
+
specRef: "basic/transports#stdio",
|
|
504
|
+
description: "Fires several JSON-RPC requests in rapid succession and verifies the server frames each response with a trailing newline per the MCP stdio transport spec.",
|
|
505
|
+
recommendation: "Emit one JSON message per line on stdout, terminated by \\n. Do not split a single message across multiple lines or merge multiple messages onto one line.",
|
|
506
|
+
transports: ["stdio"]
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
id: "stdio-unicode",
|
|
510
|
+
name: "UTF-8 unicode roundtrip",
|
|
511
|
+
category: "transport",
|
|
512
|
+
required: false,
|
|
513
|
+
specRef: "basic/transports#stdio",
|
|
514
|
+
description: "Sends a request with non-ASCII (CJK + emoji) parameters and verifies the response preserves the characters. Catches byte-level encoding mistakes in framing/parsing.",
|
|
515
|
+
recommendation: "Decode stdin as UTF-8 and encode stdout as UTF-8. Avoid latin-1 or platform-default encodings on Windows. Most JSON libraries handle this correctly if you don't override defaults.",
|
|
516
|
+
transports: ["stdio"]
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
id: "stdio-unknown-method-recovers",
|
|
520
|
+
name: "Recovers after unknown method",
|
|
521
|
+
category: "transport",
|
|
522
|
+
required: false,
|
|
523
|
+
specRef: "basic/transports#stdio",
|
|
524
|
+
description: "Sends an unknown method, then a valid ping immediately after. Verifies the server returns a JSON-RPC error for the unknown method and continues serving the subsequent request without crashing.",
|
|
525
|
+
recommendation: "Return JSON-RPC error -32601 (Method not found) for unknown methods. Do not exit the process or disconnect \u2014 the client should be able to keep using the session after an error.",
|
|
526
|
+
transports: ["stdio"]
|
|
527
|
+
},
|
|
528
|
+
// ── Lifecycle (17 tests) ─────────────────────────────────────────
|
|
529
|
+
{
|
|
530
|
+
id: "lifecycle-init",
|
|
531
|
+
name: "Initialize handshake",
|
|
532
|
+
category: "lifecycle",
|
|
533
|
+
required: true,
|
|
534
|
+
specRef: "basic/lifecycle#initialization",
|
|
535
|
+
description: "Tests the initialize handshake by sending an initialize request with client capabilities. The server must return a result with protocolVersion.",
|
|
536
|
+
recommendation: 'Implement the "initialize" method handler. Return a result object with at least protocolVersion, capabilities, and serverInfo fields.'
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: "lifecycle-proto-version",
|
|
540
|
+
name: "Returns valid protocol version",
|
|
541
|
+
category: "lifecycle",
|
|
542
|
+
required: true,
|
|
543
|
+
specRef: "basic/lifecycle#version-negotiation",
|
|
544
|
+
description: "Validates that the protocolVersion returned by the server matches the YYYY-MM-DD date format required by the spec.",
|
|
545
|
+
recommendation: `Return protocolVersion as a YYYY-MM-DD string (e.g., "2025-11-25"). The server should negotiate based on the client's requested version.`
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: "lifecycle-server-info",
|
|
549
|
+
name: "Includes serverInfo",
|
|
550
|
+
category: "lifecycle",
|
|
551
|
+
required: false,
|
|
552
|
+
specRef: "basic/lifecycle#initialization",
|
|
553
|
+
description: "Checks that the server includes a serverInfo object with at least a name field in its initialize response. While recommended, this is not strictly required.",
|
|
554
|
+
recommendation: 'Add a serverInfo object to your initialize response: { name: "your-server", version: "1.0.0" }. This helps clients identify your server.'
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
id: "lifecycle-capabilities",
|
|
558
|
+
name: "Returns capabilities object",
|
|
559
|
+
category: "lifecycle",
|
|
560
|
+
required: true,
|
|
561
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
562
|
+
description: "Verifies the server returns a capabilities object in its initialize response. An empty object is valid (no optional features declared).",
|
|
563
|
+
recommendation: "Include a capabilities object in your initialize response. Declare the features your server supports (tools, resources, prompts, logging, etc.). An empty object {} is valid."
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
id: "lifecycle-jsonrpc",
|
|
567
|
+
name: "Response is valid JSON-RPC 2.0",
|
|
568
|
+
category: "lifecycle",
|
|
569
|
+
required: true,
|
|
570
|
+
specRef: "basic",
|
|
571
|
+
description: 'Validates that the initialize response is a proper JSON-RPC 2.0 message with jsonrpc="2.0", an id field, and either a result or error field.',
|
|
572
|
+
recommendation: 'Ensure every response includes jsonrpc: "2.0", the matching id from the request, and either a result or error field. Never omit the jsonrpc field.'
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
id: "lifecycle-ping",
|
|
576
|
+
name: "Responds to ping",
|
|
577
|
+
category: "lifecycle",
|
|
578
|
+
required: true,
|
|
579
|
+
specRef: "basic/utilities#ping",
|
|
580
|
+
description: "Tests that the server responds to the ping method with an empty result object. This is a required utility method.",
|
|
581
|
+
recommendation: 'Implement a "ping" method handler that returns an empty result object {}. This is required by the MCP spec for keepalive and connectivity checking.'
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
id: "lifecycle-instructions",
|
|
585
|
+
name: "Instructions field is valid",
|
|
586
|
+
category: "lifecycle",
|
|
587
|
+
required: false,
|
|
588
|
+
specRef: "basic/lifecycle#initialization",
|
|
589
|
+
description: "If the server includes an instructions field in the initialize response, validates it is a string. Instructions provide guidance for how the client should interact with the server.",
|
|
590
|
+
recommendation: "If you include an instructions field in the initialize response, ensure it is a string. Remove the field or fix the type if it is not a string."
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
id: "lifecycle-id-match",
|
|
594
|
+
name: "Response ID matches request ID",
|
|
595
|
+
category: "lifecycle",
|
|
596
|
+
required: true,
|
|
597
|
+
specRef: "basic",
|
|
598
|
+
description: "Verifies that the JSON-RPC response id matches the request id sent by the client. This is a fundamental JSON-RPC 2.0 requirement.",
|
|
599
|
+
recommendation: "Copy the id field from the request into the response. This is a core JSON-RPC 2.0 requirement. Check that your framework does not modify or discard the request ID."
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
id: "lifecycle-string-id",
|
|
603
|
+
name: "Supports string request IDs",
|
|
604
|
+
category: "lifecycle",
|
|
605
|
+
required: false,
|
|
606
|
+
specRef: "basic",
|
|
607
|
+
description: "Sends a request with a string id instead of a number. JSON-RPC 2.0 allows both string and number IDs. The server must echo back the exact string id in the response.",
|
|
608
|
+
recommendation: "Ensure your JSON-RPC implementation supports both string and number request IDs. Echo the id back exactly as received, preserving its type."
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "lifecycle-version-negotiate",
|
|
612
|
+
name: "Handles unknown protocol version",
|
|
613
|
+
category: "lifecycle",
|
|
614
|
+
required: false,
|
|
615
|
+
specRef: "basic/lifecycle#version-negotiation",
|
|
616
|
+
description: 'Sends an initialize request with a future protocol version ("2099-01-01") and verifies the server either negotiates down to a version it supports or returns an error.',
|
|
617
|
+
recommendation: "When the client requests an unsupported protocol version, respond with the closest version your server supports. Do not blindly accept unknown versions."
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: "lifecycle-reinit-reject",
|
|
621
|
+
name: "Rejects second initialize request",
|
|
622
|
+
category: "lifecycle",
|
|
623
|
+
required: false,
|
|
624
|
+
specRef: "basic/lifecycle#initialization",
|
|
625
|
+
description: "Sends a second initialize request within the same session. Per spec, the client MUST NOT send initialize more than once. The server should reject it.",
|
|
626
|
+
recommendation: "Track initialization state per session. Reject duplicate initialize requests with a JSON-RPC error or HTTP 4xx. Do not reset session state on re-initialization."
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
id: "lifecycle-logging",
|
|
630
|
+
name: "logging/setLevel accepted",
|
|
631
|
+
category: "lifecycle",
|
|
632
|
+
required: false,
|
|
633
|
+
specRef: "server/utilities#logging",
|
|
634
|
+
description: "If the server declares logging capability, tests that logging/setLevel method is accepted with a valid log level.",
|
|
635
|
+
recommendation: 'If you declare logging in capabilities, implement the "logging/setLevel" handler. Accept standard log levels: debug, info, notice, warning, error, critical, alert, emergency.'
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: "lifecycle-completions",
|
|
639
|
+
name: "completion/complete accepted",
|
|
640
|
+
category: "lifecycle",
|
|
641
|
+
required: false,
|
|
642
|
+
specRef: "server/utilities#completion",
|
|
643
|
+
description: "If the server declares completions capability, tests that the completion/complete method is accepted.",
|
|
644
|
+
recommendation: 'If you declare completions in capabilities, implement the "completion/complete" handler. Return a completion object with a values array, even if empty.'
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
id: "lifecycle-cancellation",
|
|
648
|
+
name: "Handles cancellation notifications",
|
|
649
|
+
category: "lifecycle",
|
|
650
|
+
required: false,
|
|
651
|
+
specRef: "basic/utilities#cancellation",
|
|
652
|
+
description: "Tests that the server accepts notifications/cancelled without error. Servers should gracefully handle cancellation of unknown or completed requests.",
|
|
653
|
+
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."
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
id: "lifecycle-progress",
|
|
657
|
+
name: "Handles progress notifications gracefully",
|
|
658
|
+
category: "lifecycle",
|
|
659
|
+
required: false,
|
|
660
|
+
specRef: "basic/utilities#progress",
|
|
661
|
+
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.",
|
|
662
|
+
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."
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
id: "lifecycle-list-changed",
|
|
666
|
+
name: "Accepts listChanged notifications",
|
|
667
|
+
category: "lifecycle",
|
|
668
|
+
required: false,
|
|
669
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
670
|
+
description: "Sends notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed for declared capabilities and verifies the server accepts them.",
|
|
671
|
+
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."
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "lifecycle-progress-token",
|
|
675
|
+
name: "Supports progress tokens in requests",
|
|
676
|
+
category: "lifecycle",
|
|
677
|
+
required: false,
|
|
678
|
+
specRef: "basic/utilities#progress",
|
|
679
|
+
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.",
|
|
680
|
+
recommendation: "When a request includes _meta.progressToken, send notifications/progress events via SSE to report progress. Include progressToken, progress (current), and optionally total fields."
|
|
681
|
+
},
|
|
682
|
+
// ── Tools (4 tests) ──────────────────────────────────────────────
|
|
683
|
+
{
|
|
684
|
+
id: "tools-list",
|
|
685
|
+
name: "tools/list returns valid response",
|
|
686
|
+
category: "tools",
|
|
687
|
+
required: false,
|
|
688
|
+
specRef: "server/tools#listing-tools",
|
|
689
|
+
description: "Calls tools/list and validates it returns an array of tool definitions. Dynamically required at runtime if the server declares tools capability.",
|
|
690
|
+
recommendation: "Implement the tools/list handler to return { tools: [...] } with an array of tool definition objects. Each tool needs at least a name and inputSchema."
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
id: "tools-call",
|
|
694
|
+
name: "tools/call responds correctly",
|
|
695
|
+
category: "tools",
|
|
696
|
+
required: false,
|
|
697
|
+
specRef: "server/tools#calling-tools",
|
|
698
|
+
description: "Calls the first tool with empty arguments and verifies the response format. Accepts both successful results and InvalidParams errors.",
|
|
699
|
+
recommendation: "Ensure tools/call returns { content: [...] } with an array of content objects, each having a type field. Return isError: true for tool execution errors."
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: "tools-pagination",
|
|
703
|
+
name: "tools/list supports pagination",
|
|
704
|
+
category: "tools",
|
|
705
|
+
required: false,
|
|
706
|
+
specRef: "server/tools#listing-tools",
|
|
707
|
+
description: "Tests cursor-based pagination on tools/list. Validates nextCursor is a string if present and that fetching the next page returns a valid response.",
|
|
708
|
+
recommendation: "If your server has many tools, include a nextCursor string in the response. Ensure passing this cursor back in a subsequent request returns the next page."
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
id: "tools-content-types",
|
|
712
|
+
name: "Tool content items have valid types",
|
|
713
|
+
category: "tools",
|
|
714
|
+
required: false,
|
|
715
|
+
specRef: "server/tools#calling-tools",
|
|
716
|
+
description: "Validates that content items returned by tools/call have a recognized type field (text, image, audio, resource, resource_link).",
|
|
717
|
+
recommendation: 'Every content item returned by tools/call must have a type field set to one of: "text", "image", "audio", "resource", or "resource_link". Check for typos or missing type fields.'
|
|
718
|
+
},
|
|
719
|
+
// ── Resources (5 tests) ──────────────────────────────────────────
|
|
720
|
+
{
|
|
721
|
+
id: "resources-list",
|
|
722
|
+
name: "resources/list returns valid response",
|
|
723
|
+
category: "resources",
|
|
724
|
+
required: false,
|
|
725
|
+
specRef: "server/resources#listing-resources",
|
|
726
|
+
description: "Calls resources/list and validates it returns an array. Dynamically required at runtime if the server declares resources capability.",
|
|
727
|
+
recommendation: "Implement resources/list to return { resources: [...] } with an array of resource objects. Each resource needs at least a uri and name."
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
id: "resources-read",
|
|
731
|
+
name: "resources/read returns content",
|
|
732
|
+
category: "resources",
|
|
733
|
+
required: false,
|
|
734
|
+
specRef: "server/resources#reading-resources",
|
|
735
|
+
description: "Reads the first resource and validates the response contains a contents array with proper uri and text/blob fields.",
|
|
736
|
+
recommendation: "Implement resources/read to return { contents: [...] } where each item has a uri and either a text or blob field. Ensure the uri matches the requested resource."
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
id: "resources-templates",
|
|
740
|
+
name: "resources/templates/list returns valid response",
|
|
741
|
+
category: "resources",
|
|
742
|
+
required: false,
|
|
743
|
+
specRef: "server/resources#resource-templates",
|
|
744
|
+
description: "Tests the resource templates endpoint. Accepts Method not found (-32601) since templates are optional.",
|
|
745
|
+
recommendation: "If your server supports resource templates, implement resources/templates/list returning { resourceTemplates: [...] }. Otherwise, return error code -32601."
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
id: "resources-pagination",
|
|
749
|
+
name: "resources/list supports pagination",
|
|
750
|
+
category: "resources",
|
|
751
|
+
required: false,
|
|
752
|
+
specRef: "server/resources#listing-resources",
|
|
753
|
+
description: "Tests cursor-based pagination on resources/list. Validates nextCursor is a string if present and that fetching the next page works.",
|
|
754
|
+
recommendation: "If you return nextCursor in resources/list, ensure it is a string and that passing it back as cursor in the next request returns valid results."
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
id: "resources-subscribe",
|
|
758
|
+
name: "Resource subscribe/unsubscribe",
|
|
759
|
+
category: "resources",
|
|
760
|
+
required: false,
|
|
761
|
+
specRef: "server/resources#subscriptions",
|
|
762
|
+
description: "If the server declares resources.subscribe capability, tests that resources/subscribe and resources/unsubscribe methods are accepted.",
|
|
763
|
+
recommendation: "If you declare resources.subscribe capability, implement both resources/subscribe and resources/unsubscribe handlers. Both should accept a uri parameter."
|
|
764
|
+
},
|
|
765
|
+
// ── Prompts (3 tests) ────────────────────────────────────────────
|
|
766
|
+
{
|
|
767
|
+
id: "prompts-list",
|
|
768
|
+
name: "prompts/list returns valid response",
|
|
769
|
+
category: "prompts",
|
|
770
|
+
required: false,
|
|
771
|
+
specRef: "server/prompts#listing-prompts",
|
|
772
|
+
description: "Calls prompts/list and validates it returns an array. Dynamically required at runtime if the server declares prompts capability.",
|
|
773
|
+
recommendation: "Implement prompts/list to return { prompts: [...] } with an array of prompt objects. Each prompt needs at least a name field."
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
id: "prompts-get",
|
|
777
|
+
name: "prompts/get returns valid messages",
|
|
778
|
+
category: "prompts",
|
|
779
|
+
required: false,
|
|
780
|
+
specRef: "server/prompts#getting-a-prompt",
|
|
781
|
+
description: "Gets the first prompt and validates the response contains a messages array with proper role and content fields.",
|
|
782
|
+
recommendation: 'Implement prompts/get to return { messages: [...] } where each message has a role ("user" or "assistant") and a content field.'
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
id: "prompts-pagination",
|
|
786
|
+
name: "prompts/list supports pagination",
|
|
787
|
+
category: "prompts",
|
|
788
|
+
required: false,
|
|
789
|
+
specRef: "server/prompts#listing-prompts",
|
|
790
|
+
description: "Tests cursor-based pagination on prompts/list. Validates nextCursor is a string if present and that fetching the next page works.",
|
|
791
|
+
recommendation: "If you return nextCursor in prompts/list, ensure it is a string and that passing it back as cursor in the next request returns valid results."
|
|
792
|
+
},
|
|
793
|
+
// ── Error Handling (10 tests) ────────────────────────────────────
|
|
794
|
+
{
|
|
795
|
+
id: "error-unknown-method",
|
|
796
|
+
name: "Returns JSON-RPC error for unknown method",
|
|
797
|
+
category: "errors",
|
|
798
|
+
required: true,
|
|
799
|
+
specRef: "basic",
|
|
800
|
+
description: "Sends an unknown method and verifies the server returns a JSON-RPC error. The spec requires error code -32601 (Method not found).",
|
|
801
|
+
recommendation: "Return a JSON-RPC error with code -32601 (Method not found) for any unrecognized method name. Do not silently ignore unknown methods."
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
id: "error-method-code",
|
|
805
|
+
name: "Uses correct JSON-RPC error code for unknown method",
|
|
806
|
+
category: "errors",
|
|
807
|
+
required: false,
|
|
808
|
+
specRef: "basic",
|
|
809
|
+
description: "Checks the error code is specifically -32601 (Method not found) for unknown methods, as required by JSON-RPC 2.0.",
|
|
810
|
+
recommendation: "Use exactly error code -32601 for unknown methods. Do not use generic error codes like -32000. This is required by JSON-RPC 2.0."
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
id: "error-invalid-jsonrpc",
|
|
814
|
+
name: "Handles malformed JSON-RPC",
|
|
815
|
+
category: "errors",
|
|
816
|
+
required: true,
|
|
817
|
+
specRef: "basic",
|
|
818
|
+
description: "Sends a malformed JSON-RPC message (missing required fields) and verifies the server returns an error or 4xx status.",
|
|
819
|
+
recommendation: "Validate incoming JSON-RPC messages for required fields (jsonrpc, method). Return error code -32600 (Invalid Request) or HTTP 400 for malformed messages."
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: "error-invalid-json",
|
|
823
|
+
name: "Handles invalid JSON body",
|
|
824
|
+
category: "errors",
|
|
825
|
+
required: false,
|
|
826
|
+
specRef: "basic",
|
|
827
|
+
description: "Sends invalid JSON and verifies the server returns a parse error (-32700) or 4xx status code.",
|
|
828
|
+
recommendation: "Catch JSON parse errors and return error code -32700 (Parse error) with a descriptive message. Do not return 500 for malformed input."
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
id: "error-missing-params",
|
|
832
|
+
name: "Returns error for tools/call without name",
|
|
833
|
+
category: "errors",
|
|
834
|
+
required: false,
|
|
835
|
+
specRef: "server/tools#error-handling",
|
|
836
|
+
description: "Calls tools/call with an empty params object (missing required name field) and verifies an error is returned.",
|
|
837
|
+
recommendation: "Validate tools/call params and return error code -32602 (Invalid params) when the required name field is missing."
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
id: "error-parse-code",
|
|
841
|
+
name: "Returns -32700 for invalid JSON",
|
|
842
|
+
category: "errors",
|
|
843
|
+
required: false,
|
|
844
|
+
specRef: "basic",
|
|
845
|
+
description: "Checks that the server returns the specific JSON-RPC error code -32700 (Parse error) when receiving invalid JSON, as required by the JSON-RPC 2.0 specification.",
|
|
846
|
+
recommendation: "Return exactly error code -32700 for JSON parse failures. Most JSON-RPC frameworks handle this automatically \u2014 check yours does not override the code."
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
id: "error-invalid-request-code",
|
|
850
|
+
name: "Returns -32600 for invalid request",
|
|
851
|
+
category: "errors",
|
|
852
|
+
required: false,
|
|
853
|
+
specRef: "basic",
|
|
854
|
+
description: "Checks that the server returns the specific JSON-RPC error code -32600 (Invalid Request) for malformed JSON-RPC messages missing required fields.",
|
|
855
|
+
recommendation: "Return exactly error code -32600 for structurally invalid JSON-RPC messages (e.g., missing method field). Check your JSON-RPC middleware configuration."
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
id: "tools-call-unknown",
|
|
859
|
+
name: "Returns error for unknown tool name",
|
|
860
|
+
category: "errors",
|
|
861
|
+
required: false,
|
|
862
|
+
specRef: "server/tools#error-handling",
|
|
863
|
+
description: "Calls tools/call with a nonexistent tool name and verifies the server returns an error response.",
|
|
864
|
+
recommendation: "Return a JSON-RPC error or set isError: true when tools/call receives an unrecognized tool name. Do not return an empty success response."
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
id: "error-capability-gated",
|
|
868
|
+
name: "Rejects methods for undeclared capabilities",
|
|
869
|
+
category: "errors",
|
|
870
|
+
required: false,
|
|
871
|
+
specRef: "basic/lifecycle#capability-negotiation",
|
|
872
|
+
description: "Calls list methods (tools/list, resources/list, prompts/list) for capabilities the server did NOT declare, and verifies the server returns an error instead of success.",
|
|
873
|
+
recommendation: "Return a JSON-RPC error (e.g., -32601 Method not found) for methods associated with capabilities not declared in your initialize response."
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
id: "error-invalid-cursor",
|
|
877
|
+
name: "Handles invalid pagination cursor gracefully",
|
|
878
|
+
category: "errors",
|
|
879
|
+
required: false,
|
|
880
|
+
specRef: "basic",
|
|
881
|
+
description: "Sends a garbage pagination cursor to a list method and verifies the server handles it gracefully \u2014 either returning an error or ignoring the invalid cursor.",
|
|
882
|
+
recommendation: "Validate pagination cursors before use. Return a JSON-RPC error for unrecognized cursors, or treat invalid cursors as a request for the first page."
|
|
883
|
+
},
|
|
884
|
+
// ── Schema Validation (6 tests) ──────────────────────────────────
|
|
885
|
+
{
|
|
886
|
+
id: "tools-schema",
|
|
887
|
+
name: "All tools have name and inputSchema",
|
|
888
|
+
category: "schema",
|
|
889
|
+
required: false,
|
|
890
|
+
specRef: "server/tools#data-types",
|
|
891
|
+
description: 'Validates every tool has a valid name (1-128 chars, alphanumeric/underscore/hyphen/dot) and a required inputSchema of type "object".',
|
|
892
|
+
recommendation: 'Ensure every tool has a name (1-128 chars, [A-Za-z0-9_.-]) and an inputSchema with type: "object". Add descriptions to tools for better AI assistant integration.'
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
id: "tools-annotations",
|
|
896
|
+
name: "Tool annotations are valid",
|
|
897
|
+
category: "schema",
|
|
898
|
+
required: false,
|
|
899
|
+
specRef: "server/tools#annotations",
|
|
900
|
+
description: "Validates tool annotation fields if present: readOnlyHint, destructiveHint, idempotentHint, openWorldHint should be booleans.",
|
|
901
|
+
recommendation: "If you include annotations on tools, ensure readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are booleans. Note: title belongs on the Tool object, not inside annotations."
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
id: "tools-title-field",
|
|
905
|
+
name: "Tools include title field",
|
|
906
|
+
category: "schema",
|
|
907
|
+
required: false,
|
|
908
|
+
specRef: "server/tools#data-types",
|
|
909
|
+
description: "Checks if tools include the optional title field for human-readable display names. Added in spec version 2025-11-25.",
|
|
910
|
+
recommendation: "Add a title field (human-readable string) to each tool definition. This helps MCP clients display your tools in a user-friendly way."
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
id: "tools-output-schema",
|
|
914
|
+
name: "Tools with outputSchema are valid",
|
|
915
|
+
category: "schema",
|
|
916
|
+
required: false,
|
|
917
|
+
specRef: "server/tools#structured-content",
|
|
918
|
+
description: 'If tools declare an outputSchema, validates it is a valid JSON Schema object with type "object". Used for structured output validation.',
|
|
919
|
+
recommendation: 'If you declare outputSchema on a tool, ensure it is a valid JSON Schema object with type: "object". Remove outputSchema if you do not need structured output.'
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
id: "prompts-schema",
|
|
923
|
+
name: "Prompts have name field",
|
|
924
|
+
category: "schema",
|
|
925
|
+
required: false,
|
|
926
|
+
specRef: "server/prompts#data-types",
|
|
927
|
+
description: "Validates every prompt has a name and that any arguments array contains items with name fields.",
|
|
928
|
+
recommendation: "Ensure every prompt has a name field. If the prompt has arguments, each argument object must include a name field."
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
id: "resources-schema",
|
|
932
|
+
name: "Resources have uri and name",
|
|
933
|
+
category: "schema",
|
|
934
|
+
required: false,
|
|
935
|
+
specRef: "server/resources#data-types",
|
|
936
|
+
description: "Validates every resource has a valid URI (parseable as a URL) and a name field.",
|
|
937
|
+
recommendation: "Ensure every resource has a valid, parseable URI and a name field. Add description and mimeType for better client integration."
|
|
938
|
+
},
|
|
939
|
+
// ── Security: Auth & Transport (10 tests) ────────────────────────
|
|
940
|
+
{
|
|
941
|
+
id: "security-auth-required",
|
|
942
|
+
name: "Rejects unauthenticated requests",
|
|
943
|
+
category: "security",
|
|
944
|
+
required: false,
|
|
945
|
+
specRef: "basic/authorization",
|
|
946
|
+
description: "Sends a request without an Authorization header and verifies the server returns HTTP 401. Servers exposed over the network should require authentication.",
|
|
947
|
+
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."
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
id: "security-www-authenticate",
|
|
951
|
+
name: "401 responses include WWW-Authenticate header",
|
|
952
|
+
category: "security",
|
|
953
|
+
required: false,
|
|
954
|
+
specRef: "basic/authorization",
|
|
955
|
+
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.",
|
|
956
|
+
recommendation: `Include a WWW-Authenticate header in 401 responses to indicate the required auth scheme (e.g., 'WWW-Authenticate: Bearer realm="mcp"').`
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
id: "security-auth-malformed",
|
|
960
|
+
name: "Rejects malformed auth credentials",
|
|
961
|
+
category: "security",
|
|
962
|
+
required: false,
|
|
963
|
+
specRef: "basic/authorization",
|
|
964
|
+
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.",
|
|
965
|
+
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."
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
id: "security-tls-required",
|
|
969
|
+
name: "Enforces HTTPS/TLS",
|
|
970
|
+
category: "security",
|
|
971
|
+
required: false,
|
|
972
|
+
specRef: "basic/authorization",
|
|
973
|
+
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.",
|
|
974
|
+
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."
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
id: "security-session-entropy",
|
|
978
|
+
name: "Session IDs are high-entropy",
|
|
979
|
+
category: "security",
|
|
980
|
+
required: false,
|
|
981
|
+
specRef: "basic/transports#streamable-http",
|
|
982
|
+
description: "Analyzes the MCP-Session-Id returned by the server. Session IDs should be cryptographically random and not sequential or predictable.",
|
|
983
|
+
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."
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
id: "security-session-not-auth",
|
|
987
|
+
name: "Session ID does not bypass auth",
|
|
988
|
+
category: "security",
|
|
989
|
+
required: false,
|
|
990
|
+
specRef: "basic/transports#streamable-http",
|
|
991
|
+
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.",
|
|
992
|
+
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."
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
id: "security-oauth-metadata",
|
|
996
|
+
name: "Protected Resource Metadata endpoint exists",
|
|
997
|
+
category: "security",
|
|
998
|
+
required: false,
|
|
999
|
+
specRef: "basic/authorization",
|
|
1000
|
+
description: "Checks for a Protected Resource Metadata (RFC 9728) endpoint at /.well-known/oauth-protected-resource. Per MCP 2025-11-25, the MCP server publishes PRM with a resource identifier and authorization_servers array. Falls back to legacy /.well-known/oauth-authorization-server with a warning.",
|
|
1001
|
+
recommendation: "Publish a Protected Resource Metadata document at /.well-known/oauth-protected-resource on your server's origin. Include 'resource' (your server's URL) and 'authorization_servers' (array of OAuth AS URLs). See RFC 9728."
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
id: "security-token-in-uri",
|
|
1005
|
+
name: "Rejects auth tokens in query string",
|
|
1006
|
+
category: "security",
|
|
1007
|
+
required: false,
|
|
1008
|
+
specRef: "basic/authorization",
|
|
1009
|
+
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.",
|
|
1010
|
+
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."
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
id: "security-cors-headers",
|
|
1014
|
+
name: "CORS headers are restrictive",
|
|
1015
|
+
category: "security",
|
|
1016
|
+
required: false,
|
|
1017
|
+
specRef: "basic/transports#streamable-http",
|
|
1018
|
+
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.",
|
|
1019
|
+
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.'
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
id: "security-origin-validation",
|
|
1023
|
+
name: "Validates Origin header on requests",
|
|
1024
|
+
category: "security",
|
|
1025
|
+
required: false,
|
|
1026
|
+
specRef: "basic/transports#streamable-http",
|
|
1027
|
+
description: "Sends a request with a suspicious Origin header (https://evil-rebinding-attack.example.com) and verifies the server rejects it. Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.",
|
|
1028
|
+
recommendation: "Validate the Origin header on all incoming requests. Reject requests from untrusted origins with HTTP 403. Maintain an allowlist of permitted origins."
|
|
1029
|
+
},
|
|
1030
|
+
// ── Security: Input Validation (6 tests) ─────────────────────────
|
|
1031
|
+
{
|
|
1032
|
+
id: "security-command-injection",
|
|
1033
|
+
name: "Resists command injection in tool params",
|
|
1034
|
+
category: "security",
|
|
1035
|
+
required: false,
|
|
1036
|
+
specRef: "server/tools#calling-tools",
|
|
1037
|
+
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.",
|
|
1038
|
+
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."
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
id: "security-sql-injection",
|
|
1042
|
+
name: "Resists SQL injection in tool params",
|
|
1043
|
+
category: "security",
|
|
1044
|
+
required: false,
|
|
1045
|
+
specRef: "server/tools#calling-tools",
|
|
1046
|
+
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.`,
|
|
1047
|
+
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."
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
id: "security-path-traversal",
|
|
1051
|
+
name: "Resists path traversal in tool params",
|
|
1052
|
+
category: "security",
|
|
1053
|
+
required: false,
|
|
1054
|
+
specRef: "server/tools#calling-tools",
|
|
1055
|
+
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.",
|
|
1056
|
+
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."
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
id: "security-ssrf-internal",
|
|
1060
|
+
name: "Resists SSRF to internal networks",
|
|
1061
|
+
category: "security",
|
|
1062
|
+
required: false,
|
|
1063
|
+
specRef: "server/tools#calling-tools",
|
|
1064
|
+
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.",
|
|
1065
|
+
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."
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
id: "security-oversized-input",
|
|
1069
|
+
name: "Handles oversized inputs gracefully",
|
|
1070
|
+
category: "security",
|
|
1071
|
+
required: false,
|
|
1072
|
+
specRef: "server/tools#calling-tools",
|
|
1073
|
+
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.",
|
|
1074
|
+
recommendation: "Implement request body size limits. Return HTTP 413 or JSON-RPC error for oversized payloads. Set explicit maxBodyLength in your HTTP server configuration."
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
id: "security-extra-params",
|
|
1078
|
+
name: "Rejects or ignores extra tool params",
|
|
1079
|
+
category: "security",
|
|
1080
|
+
required: false,
|
|
1081
|
+
specRef: "server/tools#calling-tools",
|
|
1082
|
+
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.",
|
|
1083
|
+
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."
|
|
1084
|
+
},
|
|
1085
|
+
// ── Security: Tool Integrity (4 tests) ───────────────────────────
|
|
1086
|
+
{
|
|
1087
|
+
id: "security-tool-schema-defined",
|
|
1088
|
+
name: "All tools define inputSchema",
|
|
1089
|
+
category: "security",
|
|
1090
|
+
required: false,
|
|
1091
|
+
specRef: "server/tools#data-types",
|
|
1092
|
+
description: "Verifies all tools have an inputSchema with type 'object'. Tools without schemas cannot have their inputs validated, creating an injection risk.",
|
|
1093
|
+
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."
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
id: "security-tool-rug-pull",
|
|
1097
|
+
name: "Tool definitions are stable across calls",
|
|
1098
|
+
category: "security",
|
|
1099
|
+
required: false,
|
|
1100
|
+
specRef: "server/tools#listing-tools",
|
|
1101
|
+
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.",
|
|
1102
|
+
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)."
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
id: "security-tool-description-poisoning",
|
|
1106
|
+
name: "Tool descriptions free of injection patterns",
|
|
1107
|
+
category: "security",
|
|
1108
|
+
required: false,
|
|
1109
|
+
specRef: "server/tools#data-types",
|
|
1110
|
+
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.",
|
|
1111
|
+
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."
|
|
1112
|
+
},
|
|
1113
|
+
{
|
|
1114
|
+
id: "security-tool-cross-reference",
|
|
1115
|
+
name: "Tools do not reference other tools by name",
|
|
1116
|
+
category: "security",
|
|
1117
|
+
required: false,
|
|
1118
|
+
specRef: "server/tools#data-types",
|
|
1119
|
+
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.",
|
|
1120
|
+
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."
|
|
1121
|
+
},
|
|
1122
|
+
// ── Security: Information Disclosure (3 tests) ───────────────────
|
|
1123
|
+
{
|
|
1124
|
+
id: "security-error-no-stacktrace",
|
|
1125
|
+
name: "Error responses do not leak stack traces",
|
|
1126
|
+
category: "security",
|
|
1127
|
+
required: false,
|
|
1128
|
+
specRef: "basic",
|
|
1129
|
+
description: "Triggers various error conditions and inspects responses for stack traces, file paths, and internal implementation details. Error responses should not reveal server internals.",
|
|
1130
|
+
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."
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
id: "security-error-no-internal-ip",
|
|
1134
|
+
name: "Error responses do not leak internal IPs",
|
|
1135
|
+
category: "security",
|
|
1136
|
+
required: false,
|
|
1137
|
+
specRef: "basic",
|
|
1138
|
+
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.",
|
|
1139
|
+
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."
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
id: "security-rate-limiting",
|
|
1143
|
+
name: "Rate limiting is enforced",
|
|
1144
|
+
category: "security",
|
|
1145
|
+
required: false,
|
|
1146
|
+
specRef: "basic/transports#streamable-http",
|
|
1147
|
+
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.",
|
|
1148
|
+
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."
|
|
1149
|
+
}
|
|
1150
|
+
];
|
|
1151
|
+
|
|
1152
|
+
// src/runner.ts
|
|
1153
|
+
var TEST_DEFINITIONS_MAP = new Map(TEST_DEFINITIONS.map((t) => [t.id, t]));
|
|
1154
|
+
var _require = createRequire(import.meta.url);
|
|
1155
|
+
var { version: TOOL_VERSION } = _require("../package.json");
|
|
1156
|
+
var SPEC_VERSION = "2025-11-25";
|
|
1157
|
+
var SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`;
|
|
1158
|
+
var VALID_CONTENT_TYPES = ["text", "image", "audio", "resource", "resource_link"];
|
|
1159
|
+
var INJECTION_PAYLOADS = {
|
|
1160
|
+
command: ["; cat /etc/passwd", "$(whoami)", "| ls -la", "`id`", "&& echo pwned"],
|
|
1161
|
+
sql: ["' OR 1=1 --", "'; DROP TABLE users; --", "1 UNION SELECT * FROM information_schema.tables--"],
|
|
1162
|
+
pathTraversal: ["../../etc/passwd", "..\\..\\windows\\system.ini", "%2e%2e%2f%2e%2e%2fetc%2fpasswd"],
|
|
1163
|
+
ssrf: ["http://169.254.169.254/latest/meta-data/", "http://127.0.0.1:22", "http://[::1]/", "http://10.0.0.1/"]
|
|
1164
|
+
};
|
|
1165
|
+
var STACK_TRACE_PATTERNS = [
|
|
1166
|
+
/at\s+\S+\s+\(.*:\d+:\d+\)/i,
|
|
1167
|
+
// Node.js: "at Function (file.js:10:5)"
|
|
1168
|
+
/Traceback\s+\(most recent/i,
|
|
1169
|
+
// Python
|
|
1170
|
+
/\.py",\s+line\s+\d+/i,
|
|
1171
|
+
// Python file reference
|
|
1172
|
+
/\.java:\d+\)/i,
|
|
1173
|
+
// Java
|
|
1174
|
+
/\.go:\d+/i,
|
|
1175
|
+
// Go
|
|
1176
|
+
/from\s+\S+\.rb:\d+/i,
|
|
1177
|
+
// Ruby
|
|
1178
|
+
/\.cs:line\s+\d+/i,
|
|
1179
|
+
// C#/.NET
|
|
1180
|
+
/#\d+\s+\/.*\.php\(\d+\)/i,
|
|
1181
|
+
// PHP
|
|
1182
|
+
/panicked\s+at\s+'/i,
|
|
1183
|
+
// Rust
|
|
1184
|
+
/ENOENT|EACCES|EPERM/,
|
|
1185
|
+
// Node.js system errors
|
|
1186
|
+
/node_modules\//,
|
|
1187
|
+
// Node.js module paths
|
|
1188
|
+
/\/usr\/local\/|\/home\//,
|
|
1189
|
+
// Unix paths
|
|
1190
|
+
/[A-Z]:\\.*\\/,
|
|
1191
|
+
// Windows paths
|
|
1192
|
+
/password|passwd|secret|credential/i,
|
|
1193
|
+
// Sensitive terms
|
|
1194
|
+
/jdbc:|mysql:|postgres:|mongodb:/i
|
|
1195
|
+
// DB connection strings
|
|
1196
|
+
];
|
|
1197
|
+
var INTERNAL_IP_PATTERNS = [
|
|
1198
|
+
/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
1199
|
+
/\b172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/,
|
|
1200
|
+
/\b192\.168\.\d{1,3}\.\d{1,3}\b/,
|
|
1201
|
+
/\b127\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
1202
|
+
/\b::1\b/,
|
|
1203
|
+
// IPv6 loopback
|
|
1204
|
+
/\bfe80:/i,
|
|
1205
|
+
// IPv6 link-local
|
|
1206
|
+
/\bf[cd][0-9a-f]{2}:/i
|
|
1207
|
+
// IPv6 unique local (fc00::/fd00::)
|
|
1208
|
+
];
|
|
1209
|
+
function createIdCounter(start = 0) {
|
|
1210
|
+
let id = start;
|
|
1211
|
+
return () => ++id;
|
|
1212
|
+
}
|
|
1213
|
+
var STDIO_INCOMPATIBLE_IDS = /* @__PURE__ */ new Set([
|
|
1214
|
+
// Lifecycle tests that use raw undici for HTTP-specific checks
|
|
1215
|
+
"lifecycle-string-id",
|
|
1216
|
+
// Lifecycle tests that interpret HTTP status codes — for stdio these
|
|
1217
|
+
// would always read as the wrapper's default 200 and produce
|
|
1218
|
+
// misleading "(HTTP 200)" failure messages.
|
|
1219
|
+
"lifecycle-reinit-reject",
|
|
1220
|
+
"lifecycle-cancellation",
|
|
1221
|
+
"lifecycle-progress",
|
|
1222
|
+
"lifecycle-progress-token",
|
|
1223
|
+
// Error tests that send hand-crafted malformed bytes via raw HTTP
|
|
1224
|
+
// (JSON-RPC layer would reject them before they hit the wire). Could
|
|
1225
|
+
// be reimplemented for stdio later by writing raw bytes to stdin.
|
|
1226
|
+
"error-invalid-jsonrpc",
|
|
1227
|
+
"error-invalid-json",
|
|
1228
|
+
"error-parse-code",
|
|
1229
|
+
"error-invalid-request-code",
|
|
1230
|
+
// Security tests that are inherently HTTP-layer (auth headers,
|
|
1231
|
+
// sessions, CORS, TLS, rate limits, RFC 9728 metadata). For stdio
|
|
1232
|
+
// servers these don't apply — the parent process owns the trust
|
|
1233
|
+
// boundary, not the server.
|
|
1234
|
+
"security-tls-required",
|
|
1235
|
+
"security-oauth-metadata",
|
|
1236
|
+
"security-token-in-uri",
|
|
1237
|
+
"security-rate-limiting",
|
|
1238
|
+
"security-cors-headers",
|
|
1239
|
+
"security-origin-validation",
|
|
1240
|
+
"security-session-not-auth",
|
|
1241
|
+
"security-auth-required",
|
|
1242
|
+
"security-auth-malformed",
|
|
1243
|
+
"security-www-authenticate",
|
|
1244
|
+
"security-session-entropy"
|
|
1245
|
+
]);
|
|
1246
|
+
function supportsTransport(def, kind) {
|
|
1247
|
+
if (!def) return true;
|
|
1248
|
+
if (def.transports) return def.transports.includes(kind);
|
|
1249
|
+
if (kind === "http") return true;
|
|
1250
|
+
if (def.category === "transport") return false;
|
|
1251
|
+
if (STDIO_INCOMPATIBLE_IDS.has(def.id)) return false;
|
|
1252
|
+
return true;
|
|
1253
|
+
}
|
|
1254
|
+
function previewTests(opts = {}) {
|
|
1255
|
+
const transport = opts.transport ?? "http";
|
|
1256
|
+
return TEST_DEFINITIONS.filter((def) => {
|
|
1257
|
+
if (!supportsTransport(def, transport)) return false;
|
|
1258
|
+
if (opts.only?.length) {
|
|
1259
|
+
if (!opts.only.includes(def.category) && !opts.only.includes(def.id)) return false;
|
|
1260
|
+
}
|
|
1261
|
+
if (opts.skip?.length) {
|
|
1262
|
+
if (opts.skip.includes(def.category) || opts.skip.includes(def.id)) return false;
|
|
1263
|
+
}
|
|
1264
|
+
return true;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
async function runComplianceSuite(target, options = {}) {
|
|
1268
|
+
const resolvedTarget = typeof target === "string" ? { type: "http", url: target, headers: options.headers } : target;
|
|
1269
|
+
if (resolvedTarget.type === "http") {
|
|
1270
|
+
try {
|
|
1271
|
+
const parsed = new URL(resolvedTarget.url);
|
|
1272
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
1273
|
+
throw new Error("Only HTTP and HTTPS URLs are supported");
|
|
1274
|
+
}
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
if (e instanceof Error && e.message.includes("Only HTTP")) throw e;
|
|
1277
|
+
throw new Error(`Invalid URL: ${resolvedTarget.url}`);
|
|
1278
|
+
}
|
|
1279
|
+
} else if (!resolvedTarget.command) {
|
|
1280
|
+
throw new Error("stdio target requires a command");
|
|
1281
|
+
}
|
|
1282
|
+
const transport = resolvedTarget.type === "http" ? createHttpTransport({
|
|
1283
|
+
url: resolvedTarget.url,
|
|
1284
|
+
headers: resolvedTarget.headers ?? options.headers
|
|
1285
|
+
}) : createStdioTransport({
|
|
1286
|
+
command: resolvedTarget.command,
|
|
1287
|
+
args: resolvedTarget.args,
|
|
1288
|
+
env: resolvedTarget.env,
|
|
1289
|
+
cwd: resolvedTarget.cwd,
|
|
1290
|
+
verbose: resolvedTarget.verbose
|
|
1291
|
+
});
|
|
1292
|
+
try {
|
|
1293
|
+
let buildHeaders2 = function() {
|
|
1294
|
+
const h = { ...userHeaders };
|
|
1295
|
+
if (sessionId) h["mcp-session-id"] = sessionId;
|
|
1296
|
+
if (negotiatedProtocolVersion) h["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1297
|
+
return h;
|
|
1298
|
+
}, shouldRun2 = function(id, category) {
|
|
1299
|
+
const def = TEST_DEFINITIONS_MAP.get(id);
|
|
1300
|
+
if (!supportsTransport(def, transport.kind)) return false;
|
|
1301
|
+
if (options.only && options.only.length > 0) {
|
|
1302
|
+
return options.only.includes(category) || options.only.includes(id);
|
|
1303
|
+
}
|
|
1304
|
+
if (options.skip && options.skip.length > 0) {
|
|
1305
|
+
return !options.skip.includes(category) && !options.skip.includes(id);
|
|
1306
|
+
}
|
|
1307
|
+
return true;
|
|
1308
|
+
}, looksRejected2 = function(text, isErrorFlag) {
|
|
1309
|
+
if (isErrorFlag) return true;
|
|
1310
|
+
return REJECTION_PATTERNS.some((p) => p.test(text));
|
|
1311
|
+
};
|
|
1312
|
+
var buildHeaders = buildHeaders2, shouldRun = shouldRun2, looksRejected = looksRejected2;
|
|
1313
|
+
const backendUrl = resolvedTarget.type === "http" ? resolvedTarget.url : "";
|
|
1314
|
+
const userHeaders = resolvedTarget.type === "http" ? resolvedTarget.headers ?? options.headers ?? {} : {};
|
|
1315
|
+
const displayUrl = resolvedTarget.type === "http" ? resolvedTarget.url : `stdio:${resolvedTarget.command}${resolvedTarget.args?.length ? ` ${resolvedTarget.args.join(" ")}` : ""}`;
|
|
1316
|
+
let serverReachable = true;
|
|
1317
|
+
if (resolvedTarget.type === "http") {
|
|
1318
|
+
try {
|
|
1319
|
+
const preflightTimeout = options.preflightTimeout ?? Math.min(options.timeout || 15e3, 1e4);
|
|
1320
|
+
const preflight = await request2(resolvedTarget.url, {
|
|
1321
|
+
method: "POST",
|
|
1322
|
+
headers: {
|
|
1323
|
+
"Content-Type": "application/json",
|
|
1324
|
+
Accept: "application/json, text/event-stream",
|
|
1325
|
+
...userHeaders
|
|
1326
|
+
},
|
|
1327
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
|
|
1328
|
+
signal: AbortSignal.timeout(preflightTimeout)
|
|
1329
|
+
});
|
|
1330
|
+
await preflight.body.text();
|
|
1331
|
+
} catch {
|
|
1332
|
+
serverReachable = false;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const tests = [];
|
|
1336
|
+
const warnings = [];
|
|
1337
|
+
if (!serverReachable) {
|
|
1338
|
+
warnings.push(
|
|
1339
|
+
`Server at ${displayUrl} is unreachable \u2014 all tests will fail. Check the URL or command and ensure the server is running.`
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
const nextId = createIdCounter(1e3);
|
|
1343
|
+
const timeout = options.timeout || 15e3;
|
|
1344
|
+
const retries = options.retries || 0;
|
|
1345
|
+
let sessionId = null;
|
|
1346
|
+
let negotiatedProtocolVersion = null;
|
|
1347
|
+
async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs) {
|
|
1348
|
+
const res = await transport.request(method, params, idCounter, {
|
|
1349
|
+
timeout: timeoutMs,
|
|
1350
|
+
headers: extraHeaders
|
|
1351
|
+
});
|
|
1352
|
+
return {
|
|
1353
|
+
statusCode: res.statusCode ?? 200,
|
|
1354
|
+
body: res.body,
|
|
1355
|
+
headers: res.headers ?? {},
|
|
1356
|
+
requestId: res.requestId
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
async function mcpNotification(_backendUrl, method, params, extraHeaders, timeoutMs) {
|
|
1360
|
+
const res = await transport.notify(method, params, {
|
|
1361
|
+
timeout: timeoutMs,
|
|
1362
|
+
headers: extraHeaders
|
|
1363
|
+
});
|
|
1364
|
+
return { statusCode: res.statusCode ?? 200, headers: res.headers ?? {} };
|
|
1365
|
+
}
|
|
1366
|
+
const rpc = (method, params) => mcpRequest(backendUrl, method, params, nextId, buildHeaders2(), timeout);
|
|
1367
|
+
const serverInfo = {
|
|
1368
|
+
protocolVersion: null,
|
|
1369
|
+
name: null,
|
|
1370
|
+
version: null,
|
|
1371
|
+
capabilities: {}
|
|
1372
|
+
};
|
|
1373
|
+
let toolCount = 0;
|
|
1374
|
+
let toolNames = [];
|
|
1375
|
+
let resourceCount = 0;
|
|
1376
|
+
let resourceNames = [];
|
|
1377
|
+
let promptCount = 0;
|
|
1378
|
+
let promptNames = [];
|
|
1379
|
+
async function test(id, name, category, required, specRef, fn) {
|
|
1380
|
+
if (!shouldRun2(id, category)) return;
|
|
1381
|
+
const start = Date.now();
|
|
1382
|
+
let lastResult = { passed: false, details: "" };
|
|
1383
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1384
|
+
try {
|
|
1385
|
+
lastResult = await fn();
|
|
1386
|
+
if (lastResult.passed) break;
|
|
1387
|
+
if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1390
|
+
lastResult = { passed: false, details: `Error: ${message}` };
|
|
1391
|
+
if (attempt < retries) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
tests.push({
|
|
1395
|
+
id,
|
|
1396
|
+
name,
|
|
1397
|
+
category,
|
|
1398
|
+
required,
|
|
1399
|
+
passed: lastResult.passed,
|
|
1400
|
+
details: lastResult.details,
|
|
1401
|
+
durationMs: Date.now() - start,
|
|
1402
|
+
specRef: `${SPEC_BASE}/${specRef}`
|
|
1403
|
+
});
|
|
1404
|
+
options.onProgress?.(id, lastResult.passed, lastResult.details);
|
|
1405
|
+
}
|
|
1406
|
+
await test(
|
|
1407
|
+
"transport-post",
|
|
1408
|
+
"HTTP POST accepted",
|
|
1409
|
+
"transport",
|
|
1410
|
+
true,
|
|
1411
|
+
"basic/transports#streamable-http",
|
|
1412
|
+
async () => {
|
|
1413
|
+
const res = await request2(backendUrl, {
|
|
1414
|
+
method: "POST",
|
|
1415
|
+
headers: {
|
|
1416
|
+
"Content-Type": "application/json",
|
|
1417
|
+
Accept: "application/json, text/event-stream",
|
|
1418
|
+
...userHeaders
|
|
1419
|
+
},
|
|
1420
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99901, method: "ping" }),
|
|
1421
|
+
signal: AbortSignal.timeout(timeout)
|
|
1422
|
+
});
|
|
1423
|
+
const text = await res.body.text();
|
|
1424
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1425
|
+
return { passed: true, details: `HTTP ${res.statusCode}` };
|
|
1426
|
+
}
|
|
1427
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
1428
|
+
return { passed: false, details: `HTTP ${res.statusCode} (auth required \u2014 pass --auth)` };
|
|
1429
|
+
}
|
|
1430
|
+
if (res.statusCode === 400) {
|
|
1431
|
+
try {
|
|
1432
|
+
const body = JSON.parse(text);
|
|
1433
|
+
if (body?.error || body?.jsonrpc) {
|
|
1434
|
+
return {
|
|
1435
|
+
passed: true,
|
|
1436
|
+
details: "HTTP 400 with JSON-RPC response (server requires initialization first)"
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
} catch {
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1443
|
+
}
|
|
1444
|
+
);
|
|
1445
|
+
await test(
|
|
1446
|
+
"transport-content-type",
|
|
1447
|
+
"Responds with JSON or SSE",
|
|
1448
|
+
"transport",
|
|
1449
|
+
true,
|
|
1450
|
+
"basic/transports#streamable-http",
|
|
1451
|
+
async () => {
|
|
1452
|
+
const res = await request2(backendUrl, {
|
|
1453
|
+
method: "POST",
|
|
1454
|
+
headers: {
|
|
1455
|
+
"Content-Type": "application/json",
|
|
1456
|
+
Accept: "application/json, text/event-stream",
|
|
1457
|
+
...userHeaders
|
|
1458
|
+
},
|
|
1459
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99902, method: "ping" }),
|
|
1460
|
+
signal: AbortSignal.timeout(timeout)
|
|
1461
|
+
});
|
|
1462
|
+
await res.body.text();
|
|
1463
|
+
const rawCt = res.headers["content-type"];
|
|
1464
|
+
const ct = (Array.isArray(rawCt) ? rawCt[0] : rawCt || "").toLowerCase();
|
|
1465
|
+
const valid = ct.includes("application/json") || ct.includes("text/event-stream");
|
|
1466
|
+
return { passed: valid, details: `Content-Type: ${ct}` };
|
|
1467
|
+
}
|
|
1468
|
+
);
|
|
1469
|
+
await test(
|
|
1470
|
+
"transport-content-type-reject",
|
|
1471
|
+
"Rejects non-JSON request Content-Type",
|
|
1472
|
+
"transport",
|
|
1473
|
+
false,
|
|
1474
|
+
"basic/transports#streamable-http",
|
|
1475
|
+
async () => {
|
|
1476
|
+
const res = await request2(backendUrl, {
|
|
1477
|
+
method: "POST",
|
|
1478
|
+
headers: { "Content-Type": "text/plain", Accept: "application/json, text/event-stream", ...userHeaders },
|
|
1479
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99905, method: "ping" }),
|
|
1480
|
+
signal: AbortSignal.timeout(timeout)
|
|
1481
|
+
});
|
|
1482
|
+
await res.body.text();
|
|
1483
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
1484
|
+
return { passed: true, details: `HTTP ${res.statusCode} (incorrect Content-Type rejected)` };
|
|
1485
|
+
}
|
|
1486
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1487
|
+
return {
|
|
1488
|
+
passed: false,
|
|
1489
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted text/plain Content-Type (should require application/json)`
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
1493
|
+
}
|
|
1494
|
+
);
|
|
1495
|
+
await test(
|
|
1496
|
+
"transport-get",
|
|
1497
|
+
"GET returns SSE stream or 405",
|
|
1498
|
+
"transport",
|
|
1499
|
+
false,
|
|
1500
|
+
"basic/transports#streamable-http",
|
|
1501
|
+
async () => {
|
|
1502
|
+
const getHeaders = { Accept: "text/event-stream", ...buildHeaders2() };
|
|
1503
|
+
const res = await request2(backendUrl, {
|
|
1504
|
+
method: "GET",
|
|
1505
|
+
headers: getHeaders,
|
|
1506
|
+
signal: AbortSignal.timeout(timeout)
|
|
1507
|
+
});
|
|
1508
|
+
const body = await res.body.text();
|
|
1509
|
+
const rawCt = res.headers["content-type"];
|
|
1510
|
+
const ct = (Array.isArray(rawCt) ? rawCt[0] : rawCt || "").toLowerCase();
|
|
1511
|
+
if (res.statusCode === 405) {
|
|
1512
|
+
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
1513
|
+
}
|
|
1514
|
+
if (ct.includes("text/event-stream")) {
|
|
1515
|
+
if (body.trim().length > 0) {
|
|
1516
|
+
const hasDataFields = body.includes("data:");
|
|
1517
|
+
const hasEventFields = body.includes("event:");
|
|
1518
|
+
if (!hasDataFields && !hasEventFields) {
|
|
1519
|
+
return {
|
|
1520
|
+
passed: false,
|
|
1521
|
+
details: "Content-Type is text/event-stream but body has no SSE data: or event: fields"
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return { passed: true, details: "Returns text/event-stream with valid SSE format" };
|
|
1526
|
+
}
|
|
1527
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1528
|
+
return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
|
|
1529
|
+
}
|
|
1530
|
+
return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
|
|
1531
|
+
}
|
|
1532
|
+
);
|
|
1533
|
+
await test(
|
|
1534
|
+
"transport-batch-reject",
|
|
1535
|
+
"Rejects JSON-RPC batch requests",
|
|
1536
|
+
"transport",
|
|
1537
|
+
true,
|
|
1538
|
+
"basic/transports#streamable-http",
|
|
1539
|
+
async () => {
|
|
1540
|
+
const res = await request2(backendUrl, {
|
|
1541
|
+
method: "POST",
|
|
1542
|
+
headers: {
|
|
1543
|
+
"Content-Type": "application/json",
|
|
1544
|
+
Accept: "application/json, text/event-stream",
|
|
1545
|
+
...userHeaders
|
|
1546
|
+
},
|
|
1547
|
+
body: JSON.stringify([
|
|
1548
|
+
{ jsonrpc: "2.0", id: 99903, method: "ping" },
|
|
1549
|
+
{ jsonrpc: "2.0", id: 99904, method: "ping" }
|
|
1550
|
+
]),
|
|
1551
|
+
signal: AbortSignal.timeout(timeout)
|
|
1552
|
+
});
|
|
1553
|
+
const text = await res.body.text();
|
|
1554
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
1555
|
+
return { passed: true, details: `HTTP ${res.statusCode} (batch rejected)` };
|
|
1556
|
+
}
|
|
1557
|
+
try {
|
|
1558
|
+
const body = JSON.parse(text);
|
|
1559
|
+
if (body?.error) {
|
|
1560
|
+
return { passed: true, details: `JSON-RPC error: ${body.error.code} \u2014 ${body.error.message}` };
|
|
1561
|
+
}
|
|
1562
|
+
if (Array.isArray(body)) {
|
|
1563
|
+
return { passed: false, details: "Server processed batch request (MCP forbids batch)" };
|
|
1564
|
+
}
|
|
1565
|
+
} catch {
|
|
1566
|
+
}
|
|
1567
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error or 4xx for batch request` };
|
|
1568
|
+
}
|
|
1569
|
+
);
|
|
1570
|
+
let initRes = null;
|
|
1571
|
+
try {
|
|
1572
|
+
initRes = await rpc("initialize", {
|
|
1573
|
+
protocolVersion: SPEC_VERSION,
|
|
1574
|
+
capabilities: {},
|
|
1575
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1576
|
+
});
|
|
1577
|
+
const result = initRes?.body?.result;
|
|
1578
|
+
if (result) {
|
|
1579
|
+
serverInfo.protocolVersion = result.protocolVersion || null;
|
|
1580
|
+
serverInfo.name = result.serverInfo?.name || null;
|
|
1581
|
+
serverInfo.version = result.serverInfo?.version || null;
|
|
1582
|
+
serverInfo.capabilities = result.capabilities || {};
|
|
1583
|
+
const sid = initRes.headers["mcp-session-id"];
|
|
1584
|
+
if (sid) {
|
|
1585
|
+
sessionId = sid;
|
|
1586
|
+
transport.setSessionId(sid);
|
|
1587
|
+
}
|
|
1588
|
+
if (result.protocolVersion) {
|
|
1589
|
+
negotiatedProtocolVersion = result.protocolVersion;
|
|
1590
|
+
transport.setProtocolVersion(result.protocolVersion);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
} catch {
|
|
1594
|
+
}
|
|
1595
|
+
try {
|
|
1596
|
+
await mcpNotification(backendUrl, "notifications/initialized", void 0, buildHeaders2(), timeout);
|
|
1597
|
+
} catch {
|
|
1598
|
+
}
|
|
1599
|
+
const hasTools = !!serverInfo.capabilities.tools;
|
|
1600
|
+
const hasResources = !!serverInfo.capabilities.resources;
|
|
1601
|
+
const hasPrompts = !!serverInfo.capabilities.prompts;
|
|
1602
|
+
await test(
|
|
1603
|
+
"lifecycle-init",
|
|
1604
|
+
"Initialize handshake",
|
|
1605
|
+
"lifecycle",
|
|
1606
|
+
true,
|
|
1607
|
+
"basic/lifecycle#initialization",
|
|
1608
|
+
async () => {
|
|
1609
|
+
if (!initRes) return { passed: false, details: "Initialize request failed" };
|
|
1610
|
+
const result = initRes.body?.result;
|
|
1611
|
+
if (!result) return { passed: false, details: "No result in response" };
|
|
1612
|
+
return { passed: !!result.protocolVersion, details: `Protocol: ${result.protocolVersion || "missing"}` };
|
|
1613
|
+
}
|
|
1614
|
+
);
|
|
1615
|
+
await test(
|
|
1616
|
+
"lifecycle-proto-version",
|
|
1617
|
+
"Returns valid protocol version",
|
|
1618
|
+
"lifecycle",
|
|
1619
|
+
true,
|
|
1620
|
+
"basic/lifecycle#version-negotiation",
|
|
1621
|
+
async () => {
|
|
1622
|
+
const version = initRes?.body?.result?.protocolVersion;
|
|
1623
|
+
if (!version) return { passed: false, details: "No protocolVersion" };
|
|
1624
|
+
const valid = /^\d{4}-\d{2}-\d{2}$/.test(version);
|
|
1625
|
+
if (valid && version !== SPEC_VERSION) {
|
|
1626
|
+
warnings.push(`Server negotiated protocol version ${version} (latest is ${SPEC_VERSION})`);
|
|
1627
|
+
}
|
|
1628
|
+
return { passed: valid, details: `Version: ${version}` };
|
|
1629
|
+
}
|
|
1630
|
+
);
|
|
1631
|
+
await test(
|
|
1632
|
+
"lifecycle-version-negotiate",
|
|
1633
|
+
"Handles unknown protocol version",
|
|
1634
|
+
"lifecycle",
|
|
1635
|
+
false,
|
|
1636
|
+
"basic/lifecycle#version-negotiation",
|
|
1637
|
+
async () => {
|
|
1638
|
+
try {
|
|
1639
|
+
const futureRes = await mcpRequest(
|
|
1640
|
+
backendUrl,
|
|
1641
|
+
"initialize",
|
|
1642
|
+
{
|
|
1643
|
+
protocolVersion: "2099-01-01",
|
|
1644
|
+
capabilities: {},
|
|
1645
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1646
|
+
},
|
|
1647
|
+
createIdCounter(99960),
|
|
1648
|
+
userHeaders,
|
|
1649
|
+
timeout
|
|
1650
|
+
);
|
|
1651
|
+
const result = futureRes.body?.result;
|
|
1652
|
+
const error = futureRes.body?.error;
|
|
1653
|
+
if (error) {
|
|
1654
|
+
return {
|
|
1655
|
+
passed: true,
|
|
1656
|
+
details: `Server rejected unknown version with error: ${error.code} \u2014 ${error.message}`
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
if (result?.protocolVersion) {
|
|
1660
|
+
const offered = result.protocolVersion;
|
|
1661
|
+
if (offered === "2099-01-01") {
|
|
1662
|
+
return {
|
|
1663
|
+
passed: false,
|
|
1664
|
+
details: 'Server accepted impossible future version "2099-01-01" \u2014 should offer a version it actually supports'
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
return { passed: true, details: `Server negotiated down to ${offered} (correct)` };
|
|
1668
|
+
}
|
|
1669
|
+
return { passed: false, details: "No protocolVersion or error in response" };
|
|
1670
|
+
} catch {
|
|
1671
|
+
return { passed: true, details: "Connection rejected for unknown version (acceptable)" };
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
);
|
|
1675
|
+
await test(
|
|
1676
|
+
"lifecycle-server-info",
|
|
1677
|
+
"Includes serverInfo",
|
|
1678
|
+
"lifecycle",
|
|
1679
|
+
false,
|
|
1680
|
+
"basic/lifecycle#initialization",
|
|
1681
|
+
async () => {
|
|
1682
|
+
const info = initRes?.body?.result?.serverInfo;
|
|
1683
|
+
return { passed: !!info?.name, details: info ? `${info.name} v${info.version || "?"}` : "Missing serverInfo" };
|
|
1684
|
+
}
|
|
1685
|
+
);
|
|
1686
|
+
await test(
|
|
1687
|
+
"lifecycle-capabilities",
|
|
1688
|
+
"Returns capabilities object",
|
|
1689
|
+
"lifecycle",
|
|
1690
|
+
true,
|
|
1691
|
+
"basic/lifecycle#capability-negotiation",
|
|
1692
|
+
async () => {
|
|
1693
|
+
const caps = initRes?.body?.result?.capabilities;
|
|
1694
|
+
if (!caps || typeof caps !== "object") return { passed: false, details: "No capabilities object in response" };
|
|
1695
|
+
const declared = Object.keys(caps).filter((k) => caps[k] !== void 0);
|
|
1696
|
+
return {
|
|
1697
|
+
passed: true,
|
|
1698
|
+
details: declared.length > 0 ? `Capabilities: ${declared.join(", ")}` : "Empty capabilities (valid)"
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
);
|
|
1702
|
+
await test("lifecycle-jsonrpc", "Response is valid JSON-RPC 2.0", "lifecycle", true, "basic", async () => {
|
|
1703
|
+
const body = initRes?.body;
|
|
1704
|
+
const valid = body?.jsonrpc === "2.0" && body?.id !== void 0 && (body?.result !== void 0 || body?.error !== void 0);
|
|
1705
|
+
return {
|
|
1706
|
+
passed: valid,
|
|
1707
|
+
details: valid ? "Valid JSON-RPC 2.0 response" : `Missing fields: jsonrpc=${body?.jsonrpc}, id=${body?.id}`
|
|
1708
|
+
};
|
|
1709
|
+
});
|
|
1710
|
+
await test("lifecycle-ping", "Responds to ping", "lifecycle", true, "basic/utilities#ping", async () => {
|
|
1711
|
+
const res = await rpc("ping");
|
|
1712
|
+
const body = res.body;
|
|
1713
|
+
if (body?.error) return { passed: false, details: `Error: ${body.error.message}` };
|
|
1714
|
+
if (body?.result !== void 0) return { passed: true, details: "Ping responded successfully" };
|
|
1715
|
+
return { passed: false, details: "No result in ping response" };
|
|
1716
|
+
});
|
|
1717
|
+
await test(
|
|
1718
|
+
"lifecycle-instructions",
|
|
1719
|
+
"Instructions field is valid",
|
|
1720
|
+
"lifecycle",
|
|
1721
|
+
false,
|
|
1722
|
+
"basic/lifecycle#initialization",
|
|
1723
|
+
async () => {
|
|
1724
|
+
const result = initRes?.body?.result;
|
|
1725
|
+
if (!result) return { passed: false, details: "No init result" };
|
|
1726
|
+
if (result.instructions === void 0) {
|
|
1727
|
+
return { passed: true, details: "No instructions field (optional)" };
|
|
1728
|
+
}
|
|
1729
|
+
if (typeof result.instructions === "string") {
|
|
1730
|
+
const preview = result.instructions.length > 80 ? result.instructions.slice(0, 80) + "..." : result.instructions;
|
|
1731
|
+
return { passed: true, details: `Instructions: "${preview}"` };
|
|
1732
|
+
}
|
|
1733
|
+
return { passed: false, details: `instructions should be a string, got ${typeof result.instructions}` };
|
|
1734
|
+
}
|
|
1735
|
+
);
|
|
1736
|
+
await test("lifecycle-id-match", "Response ID matches request ID", "lifecycle", true, "basic", async () => {
|
|
1737
|
+
const res = await rpc("ping");
|
|
1738
|
+
const body = res.body;
|
|
1739
|
+
if (body?.id === void 0) return { passed: false, details: "No id in response" };
|
|
1740
|
+
const match = body.id === res.requestId;
|
|
1741
|
+
return {
|
|
1742
|
+
passed: match,
|
|
1743
|
+
details: match ? `Request id=${res.requestId}, response id=${body.id} (match)` : `Request id=${res.requestId}, response id=${body.id} (MISMATCH)`
|
|
1744
|
+
};
|
|
1745
|
+
});
|
|
1746
|
+
await test("lifecycle-string-id", "Supports string request IDs", "lifecycle", false, "basic", async () => {
|
|
1747
|
+
const stringId = "compliance-test-string-id";
|
|
1748
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: stringId, method: "ping", params: {} });
|
|
1749
|
+
const res = await request2(backendUrl, {
|
|
1750
|
+
method: "POST",
|
|
1751
|
+
headers: {
|
|
1752
|
+
"Content-Type": "application/json",
|
|
1753
|
+
Accept: "application/json, text/event-stream",
|
|
1754
|
+
...buildHeaders2()
|
|
1755
|
+
},
|
|
1756
|
+
body,
|
|
1757
|
+
signal: AbortSignal.timeout(timeout)
|
|
1758
|
+
});
|
|
1759
|
+
const text = await res.body.text();
|
|
1760
|
+
const rawCtStr = res.headers["content-type"];
|
|
1761
|
+
const ct = (Array.isArray(rawCtStr) ? rawCtStr[0] : rawCtStr || "").toLowerCase();
|
|
1762
|
+
let parsed;
|
|
1763
|
+
if (ct.includes("text/event-stream")) {
|
|
1764
|
+
parsed = parseSSEResponse(text);
|
|
1765
|
+
}
|
|
1766
|
+
if (!parsed) {
|
|
1767
|
+
try {
|
|
1768
|
+
parsed = JSON.parse(text);
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
if (!parsed) return { passed: false, details: "Could not parse response" };
|
|
1773
|
+
if (parsed.id === stringId) {
|
|
1774
|
+
return { passed: true, details: `String id="${stringId}" echoed back correctly` };
|
|
1775
|
+
}
|
|
1776
|
+
if (parsed.id === void 0) {
|
|
1777
|
+
return { passed: false, details: "No id in response" };
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
passed: false,
|
|
1781
|
+
details: `String id="${stringId}" sent, got back id=${JSON.stringify(parsed.id)} (type: ${typeof parsed.id})`
|
|
1782
|
+
};
|
|
1783
|
+
});
|
|
1784
|
+
await test(
|
|
1785
|
+
"lifecycle-reinit-reject",
|
|
1786
|
+
"Rejects second initialize request",
|
|
1787
|
+
"lifecycle",
|
|
1788
|
+
false,
|
|
1789
|
+
"basic/lifecycle#initialization",
|
|
1790
|
+
async () => {
|
|
1791
|
+
try {
|
|
1792
|
+
const res = await rpc("initialize", {
|
|
1793
|
+
protocolVersion: SPEC_VERSION,
|
|
1794
|
+
capabilities: {},
|
|
1795
|
+
clientInfo: { name: "mcp-compliance", version: TOOL_VERSION }
|
|
1796
|
+
});
|
|
1797
|
+
const error = res.body?.error;
|
|
1798
|
+
if (error) {
|
|
1799
|
+
return { passed: true, details: `Re-initialization rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
1800
|
+
}
|
|
1801
|
+
if (res.statusCode >= 400) {
|
|
1802
|
+
return { passed: true, details: `HTTP ${res.statusCode} (re-initialization rejected)` };
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
passed: false,
|
|
1806
|
+
details: `Server accepted second initialize (HTTP ${res.statusCode}) \u2014 should reject duplicate initialization`
|
|
1807
|
+
};
|
|
1808
|
+
} catch {
|
|
1809
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
);
|
|
1813
|
+
const hasLogging = !!serverInfo.capabilities.logging;
|
|
1814
|
+
await test(
|
|
1815
|
+
"lifecycle-logging",
|
|
1816
|
+
"logging/setLevel accepted",
|
|
1817
|
+
"lifecycle",
|
|
1818
|
+
hasLogging,
|
|
1819
|
+
"server/utilities#logging",
|
|
1820
|
+
async () => {
|
|
1821
|
+
if (!hasLogging) return { passed: true, details: "Server does not declare logging capability (skipped)" };
|
|
1822
|
+
const res = await rpc("logging/setLevel", { level: "info" });
|
|
1823
|
+
if (res.body?.error) {
|
|
1824
|
+
return { passed: false, details: `Error: ${res.body.error.code} \u2014 ${res.body.error.message}` };
|
|
1825
|
+
}
|
|
1826
|
+
const invalidRes = await rpc("logging/setLevel", { level: "__invalid_level__" });
|
|
1827
|
+
const validatesInput = !!invalidRes.body?.error;
|
|
1828
|
+
const validLevels = ["debug", "warning", "error"];
|
|
1829
|
+
const accepted = [];
|
|
1830
|
+
for (const level of validLevels) {
|
|
1831
|
+
const r = await rpc("logging/setLevel", { level });
|
|
1832
|
+
if (!r.body?.error) accepted.push(level);
|
|
1833
|
+
}
|
|
1834
|
+
const details = validatesInput ? `logging/setLevel accepted (validates levels, ${accepted.length + 1} levels accepted)` : "logging/setLevel accepted (warning: server does not reject invalid log levels)";
|
|
1835
|
+
if (!validatesInput) warnings.push("Server accepts invalid log levels without error");
|
|
1836
|
+
return { passed: true, details };
|
|
1837
|
+
}
|
|
1838
|
+
);
|
|
1839
|
+
const hasCompletions = !!serverInfo.capabilities.completions;
|
|
1840
|
+
await test(
|
|
1841
|
+
"lifecycle-completions",
|
|
1842
|
+
"completion/complete accepted",
|
|
1843
|
+
"lifecycle",
|
|
1844
|
+
hasCompletions,
|
|
1845
|
+
"server/utilities#completion",
|
|
1846
|
+
async () => {
|
|
1847
|
+
if (!hasCompletions)
|
|
1848
|
+
return { passed: true, details: "Server does not declare completions capability (skipped)" };
|
|
1849
|
+
const res = await rpc("completion/complete", {
|
|
1850
|
+
ref: { type: "ref/prompt", name: "__test__" },
|
|
1851
|
+
argument: { name: "test", value: "" }
|
|
1852
|
+
});
|
|
1853
|
+
if (res.body?.error) {
|
|
1854
|
+
if (res.body.error.code === -32602) {
|
|
1855
|
+
return { passed: true, details: "InvalidParams for test ref (acceptable)" };
|
|
1856
|
+
}
|
|
1857
|
+
return { passed: false, details: `Error: ${res.body.error.code} \u2014 ${res.body.error.message}` };
|
|
1858
|
+
}
|
|
1859
|
+
const values = res.body?.result?.completion?.values;
|
|
1860
|
+
if (Array.isArray(values)) {
|
|
1861
|
+
return { passed: true, details: `Returned ${values.length} completion(s)` };
|
|
1862
|
+
}
|
|
1863
|
+
return { passed: true, details: "completion/complete accepted" };
|
|
1864
|
+
}
|
|
1865
|
+
);
|
|
1866
|
+
await test(
|
|
1867
|
+
"lifecycle-cancellation",
|
|
1868
|
+
"Handles cancellation notifications",
|
|
1869
|
+
"lifecycle",
|
|
1870
|
+
false,
|
|
1871
|
+
"basic/utilities#cancellation",
|
|
1872
|
+
async () => {
|
|
1873
|
+
const res = await mcpNotification(
|
|
1874
|
+
backendUrl,
|
|
1875
|
+
"notifications/cancelled",
|
|
1876
|
+
{ requestId: 99999, reason: "compliance test" },
|
|
1877
|
+
buildHeaders2(),
|
|
1878
|
+
timeout
|
|
1879
|
+
);
|
|
1880
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1881
|
+
return { passed: true, details: `HTTP ${res.statusCode} (cancellation accepted)` };
|
|
1882
|
+
}
|
|
1883
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should accept cancellation notifications` };
|
|
1884
|
+
}
|
|
1885
|
+
);
|
|
1886
|
+
await test(
|
|
1887
|
+
"lifecycle-progress",
|
|
1888
|
+
"Handles progress notifications gracefully",
|
|
1889
|
+
"lifecycle",
|
|
1890
|
+
false,
|
|
1891
|
+
"basic/utilities#progress",
|
|
1892
|
+
async () => {
|
|
1893
|
+
const res = await mcpNotification(
|
|
1894
|
+
backendUrl,
|
|
1895
|
+
"notifications/progress",
|
|
1896
|
+
{ progressToken: "compliance-test-token", progress: 50, total: 100 },
|
|
1897
|
+
buildHeaders2(),
|
|
1898
|
+
timeout
|
|
1899
|
+
);
|
|
1900
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1901
|
+
return { passed: true, details: `HTTP ${res.statusCode} (notification handled gracefully)` };
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
passed: false,
|
|
1905
|
+
details: `HTTP ${res.statusCode} \u2014 server should accept unknown notifications without error`
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
);
|
|
1909
|
+
await test(
|
|
1910
|
+
"lifecycle-list-changed",
|
|
1911
|
+
"Accepts listChanged notifications",
|
|
1912
|
+
"lifecycle",
|
|
1913
|
+
false,
|
|
1914
|
+
"basic/lifecycle#capability-negotiation",
|
|
1915
|
+
async () => {
|
|
1916
|
+
const notifications = [
|
|
1917
|
+
{ method: "notifications/tools/list_changed", gate: hasTools },
|
|
1918
|
+
{ method: "notifications/resources/list_changed", gate: hasResources },
|
|
1919
|
+
{ method: "notifications/prompts/list_changed", gate: hasPrompts }
|
|
1920
|
+
];
|
|
1921
|
+
const applicable = notifications.filter((n) => n.gate);
|
|
1922
|
+
if (applicable.length === 0) {
|
|
1923
|
+
return { passed: true, details: "No capabilities declared \u2014 listChanged notifications not applicable" };
|
|
1924
|
+
}
|
|
1925
|
+
const issues = [];
|
|
1926
|
+
for (const { method } of applicable) {
|
|
1927
|
+
try {
|
|
1928
|
+
const res = await mcpNotification(backendUrl, method, void 0, buildHeaders2(), timeout);
|
|
1929
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1930
|
+
issues.push(`${method}: HTTP ${res.statusCode}`);
|
|
1931
|
+
}
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
issues.push(`${method}: ${err instanceof Error ? err.message : "error"}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
1937
|
+
return {
|
|
1938
|
+
passed: true,
|
|
1939
|
+
details: `${applicable.length} listChanged notification(s) accepted: ${applicable.map((n) => n.method).join(", ")}`
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
);
|
|
1943
|
+
await test(
|
|
1944
|
+
"lifecycle-progress-token",
|
|
1945
|
+
"Supports progress tokens in requests",
|
|
1946
|
+
"lifecycle",
|
|
1947
|
+
false,
|
|
1948
|
+
"basic/utilities#progress",
|
|
1949
|
+
async () => {
|
|
1950
|
+
if (!hasTools || toolNames.length === 0) {
|
|
1951
|
+
return { passed: true, details: "No tools available for progress token test (skipped)" };
|
|
1952
|
+
}
|
|
1953
|
+
const progressToken = "compliance-progress-test";
|
|
1954
|
+
const reqBody = JSON.stringify({
|
|
1955
|
+
jsonrpc: "2.0",
|
|
1956
|
+
id: nextId(),
|
|
1957
|
+
method: "tools/call",
|
|
1958
|
+
params: {
|
|
1959
|
+
name: toolNames[0],
|
|
1960
|
+
arguments: {},
|
|
1961
|
+
_meta: { progressToken }
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
try {
|
|
1965
|
+
const res = await request2(backendUrl, {
|
|
1966
|
+
method: "POST",
|
|
1967
|
+
headers: {
|
|
1968
|
+
"Content-Type": "application/json",
|
|
1969
|
+
Accept: "text/event-stream",
|
|
1970
|
+
...buildHeaders2()
|
|
1971
|
+
},
|
|
1972
|
+
body: reqBody,
|
|
1973
|
+
signal: AbortSignal.timeout(timeout)
|
|
1974
|
+
});
|
|
1975
|
+
const text = await res.body.text();
|
|
1976
|
+
const rawCtProgress = res.headers["content-type"];
|
|
1977
|
+
const ct = (Array.isArray(rawCtProgress) ? rawCtProgress[0] : rawCtProgress || "").toLowerCase();
|
|
1978
|
+
if (ct.includes("text/event-stream") && text.includes("notifications/progress")) {
|
|
1979
|
+
return { passed: true, details: "Server sent progress notifications via SSE with progressToken" };
|
|
1980
|
+
}
|
|
1981
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
1982
|
+
return {
|
|
1983
|
+
passed: true,
|
|
1984
|
+
details: "Server accepted request with progressToken (no progress events observed \u2014 optional)"
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 request with progressToken accepted` };
|
|
1988
|
+
} catch {
|
|
1989
|
+
return {
|
|
1990
|
+
passed: true,
|
|
1991
|
+
details: "Request with progressToken handled (no progress events observed \u2014 optional)"
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
);
|
|
1996
|
+
await test(
|
|
1997
|
+
"transport-content-type-init",
|
|
1998
|
+
"Initialize response has valid content type",
|
|
1999
|
+
"transport",
|
|
2000
|
+
false,
|
|
2001
|
+
"basic/transports#streamable-http",
|
|
2002
|
+
async () => {
|
|
2003
|
+
if (!initRes) return { passed: false, details: "No init response to check" };
|
|
2004
|
+
const ct = (initRes.headers["content-type"] || "").toLowerCase();
|
|
2005
|
+
const valid = ct.includes("application/json") || ct.includes("text/event-stream");
|
|
2006
|
+
return { passed: valid, details: `Content-Type: ${ct || "missing"}` };
|
|
2007
|
+
}
|
|
2008
|
+
);
|
|
2009
|
+
await test(
|
|
2010
|
+
"transport-notification-202",
|
|
2011
|
+
"Notification returns 202 Accepted",
|
|
2012
|
+
"transport",
|
|
2013
|
+
false,
|
|
2014
|
+
"basic/transports#streamable-http",
|
|
2015
|
+
async () => {
|
|
2016
|
+
const res = await request2(backendUrl, {
|
|
2017
|
+
method: "POST",
|
|
2018
|
+
headers: {
|
|
2019
|
+
"Content-Type": "application/json",
|
|
2020
|
+
Accept: "application/json, text/event-stream",
|
|
2021
|
+
...buildHeaders2()
|
|
2022
|
+
},
|
|
2023
|
+
body: JSON.stringify({
|
|
2024
|
+
jsonrpc: "2.0",
|
|
2025
|
+
method: "notifications/cancelled",
|
|
2026
|
+
params: { requestId: "nonexistent", reason: "compliance test" }
|
|
2027
|
+
}),
|
|
2028
|
+
signal: AbortSignal.timeout(timeout)
|
|
2029
|
+
});
|
|
2030
|
+
await res.body.text();
|
|
2031
|
+
if (res.statusCode === 202) {
|
|
2032
|
+
return { passed: true, details: "HTTP 202 Accepted (correct)" };
|
|
2033
|
+
}
|
|
2034
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2035
|
+
warnings.push(`Notification returned HTTP ${res.statusCode} instead of spec-required 202 Accepted`);
|
|
2036
|
+
return {
|
|
2037
|
+
passed: false,
|
|
2038
|
+
details: `HTTP ${res.statusCode} \u2014 spec requires 202 Accepted for notifications (MUST)`
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected 202 Accepted for notifications` };
|
|
2042
|
+
}
|
|
2043
|
+
);
|
|
2044
|
+
await test(
|
|
2045
|
+
"transport-session-id",
|
|
2046
|
+
"Enforces MCP-Session-Id after init",
|
|
2047
|
+
"transport",
|
|
2048
|
+
false,
|
|
2049
|
+
"basic/transports#streamable-http",
|
|
2050
|
+
async () => {
|
|
2051
|
+
if (!sessionId) {
|
|
2052
|
+
warnings.push("Server did not issue MCP-Session-Id header");
|
|
2053
|
+
return { passed: true, details: "Server did not issue session ID (test not applicable)" };
|
|
2054
|
+
}
|
|
2055
|
+
const headersWithout = { ...userHeaders };
|
|
2056
|
+
if (negotiatedProtocolVersion) headersWithout["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
2057
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, createIdCounter(99910), headersWithout, timeout);
|
|
2058
|
+
if (res.statusCode === 400) {
|
|
2059
|
+
return { passed: true, details: "HTTP 400 for missing session ID (correct)" };
|
|
2060
|
+
}
|
|
2061
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2062
|
+
return {
|
|
2063
|
+
passed: false,
|
|
2064
|
+
details: `HTTP ${res.statusCode} \u2014 server should return 400 when session ID is missing`
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
2068
|
+
}
|
|
2069
|
+
);
|
|
2070
|
+
await test(
|
|
2071
|
+
"transport-session-invalid",
|
|
2072
|
+
"Returns 404 for unknown session ID",
|
|
2073
|
+
"transport",
|
|
2074
|
+
false,
|
|
2075
|
+
"basic/transports#streamable-http",
|
|
2076
|
+
async () => {
|
|
2077
|
+
if (!sessionId) {
|
|
2078
|
+
return { passed: true, details: "Server did not issue session ID (test not applicable)" };
|
|
2079
|
+
}
|
|
2080
|
+
const fakeHeaders = {
|
|
2081
|
+
...userHeaders,
|
|
2082
|
+
"mcp-session-id": "invalid-nonexistent-session-id"
|
|
2083
|
+
};
|
|
2084
|
+
if (negotiatedProtocolVersion) fakeHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
2085
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, createIdCounter(99915), fakeHeaders, timeout);
|
|
2086
|
+
if (res.statusCode === 404) {
|
|
2087
|
+
return { passed: true, details: "HTTP 404 for unknown session ID (correct per spec)" };
|
|
2088
|
+
}
|
|
2089
|
+
if (res.statusCode === 400) {
|
|
2090
|
+
return {
|
|
2091
|
+
passed: false,
|
|
2092
|
+
details: "HTTP 400 \u2014 spec requires 404 (Not Found) for unrecognized session IDs, not 400"
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 spec requires 404 for unrecognized MCP-Session-Id` };
|
|
2096
|
+
}
|
|
2097
|
+
);
|
|
2098
|
+
await test(
|
|
2099
|
+
"transport-get-stream",
|
|
2100
|
+
"GET with session returns SSE or 405",
|
|
2101
|
+
"transport",
|
|
2102
|
+
false,
|
|
2103
|
+
"basic/transports#streamable-http",
|
|
2104
|
+
async () => {
|
|
2105
|
+
if (!sessionId) {
|
|
2106
|
+
return { passed: true, details: "No session ID \u2014 server-initiated messages not applicable" };
|
|
2107
|
+
}
|
|
2108
|
+
const res = await request2(backendUrl, {
|
|
2109
|
+
method: "GET",
|
|
2110
|
+
headers: { Accept: "text/event-stream", ...buildHeaders2() },
|
|
2111
|
+
signal: AbortSignal.timeout(Math.min(timeout, 3e3))
|
|
2112
|
+
});
|
|
2113
|
+
const body = await res.body.text();
|
|
2114
|
+
const rawCt2 = res.headers["content-type"];
|
|
2115
|
+
const ct = (Array.isArray(rawCt2) ? rawCt2[0] : rawCt2 || "").toLowerCase();
|
|
2116
|
+
if (res.statusCode === 405) {
|
|
2117
|
+
return { passed: true, details: "HTTP 405 (server does not support server-initiated messages)" };
|
|
2118
|
+
}
|
|
2119
|
+
if (ct.includes("text/event-stream")) {
|
|
2120
|
+
if (body.trim().length > 0) {
|
|
2121
|
+
const hasSSEFields = body.includes("data:") || body.includes("event:");
|
|
2122
|
+
if (!hasSSEFields) {
|
|
2123
|
+
return { passed: false, details: "Content-Type is text/event-stream but body has no SSE fields" };
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return { passed: true, details: "GET with session returns SSE stream for server-initiated messages" };
|
|
2127
|
+
}
|
|
2128
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
2129
|
+
return { passed: true, details: `HTTP ${res.statusCode} (accepted)` };
|
|
2130
|
+
}
|
|
2131
|
+
return { passed: false, details: `HTTP ${res.statusCode}, Content-Type: ${ct}` };
|
|
2132
|
+
}
|
|
2133
|
+
);
|
|
2134
|
+
await test(
|
|
2135
|
+
"transport-concurrent",
|
|
2136
|
+
"Handles concurrent requests",
|
|
2137
|
+
"transport",
|
|
2138
|
+
false,
|
|
2139
|
+
"basic/transports#streamable-http",
|
|
2140
|
+
async () => {
|
|
2141
|
+
const ids = [createIdCounter(99930)(), createIdCounter(99931)(), createIdCounter(99932)()];
|
|
2142
|
+
const promises = ids.map(
|
|
2143
|
+
(id) => request2(backendUrl, {
|
|
2144
|
+
method: "POST",
|
|
2145
|
+
headers: {
|
|
2146
|
+
"Content-Type": "application/json",
|
|
2147
|
+
Accept: "application/json, text/event-stream",
|
|
2148
|
+
...buildHeaders2()
|
|
2149
|
+
},
|
|
2150
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method: "ping" }),
|
|
2151
|
+
signal: AbortSignal.timeout(timeout)
|
|
2152
|
+
}).then(async (res) => {
|
|
2153
|
+
const text = await res.body.text();
|
|
2154
|
+
const rawCtConcurrent = res.headers["content-type"];
|
|
2155
|
+
const ct = (Array.isArray(rawCtConcurrent) ? rawCtConcurrent[0] : rawCtConcurrent || "").toLowerCase();
|
|
2156
|
+
let body;
|
|
2157
|
+
if (ct.includes("text/event-stream")) {
|
|
2158
|
+
body = parseSSEResponse(text);
|
|
2159
|
+
}
|
|
2160
|
+
if (!body) {
|
|
2161
|
+
try {
|
|
2162
|
+
body = JSON.parse(text);
|
|
2163
|
+
} catch {
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return { statusCode: res.statusCode, body, requestId: id };
|
|
2167
|
+
})
|
|
2168
|
+
);
|
|
2169
|
+
const results = await Promise.all(promises);
|
|
2170
|
+
const issues = [];
|
|
2171
|
+
for (const r of results) {
|
|
2172
|
+
if (r.statusCode < 200 || r.statusCode >= 300) {
|
|
2173
|
+
issues.push(`Request id=${r.requestId}: HTTP ${r.statusCode}`);
|
|
2174
|
+
} else if (r.body?.id !== r.requestId) {
|
|
2175
|
+
issues.push(`Request id=${r.requestId}: response id=${r.body?.id} (mismatch)`);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2179
|
+
return { passed: true, details: `${results.length} concurrent requests handled correctly` };
|
|
2180
|
+
}
|
|
2181
|
+
);
|
|
2182
|
+
await test(
|
|
2183
|
+
"transport-sse-event-field",
|
|
2184
|
+
"SSE responses include event: message",
|
|
2185
|
+
"transport",
|
|
2186
|
+
false,
|
|
2187
|
+
"basic/transports#streamable-http",
|
|
2188
|
+
async () => {
|
|
2189
|
+
const res = await request2(backendUrl, {
|
|
2190
|
+
method: "POST",
|
|
2191
|
+
headers: {
|
|
2192
|
+
"Content-Type": "application/json",
|
|
2193
|
+
Accept: "text/event-stream",
|
|
2194
|
+
...buildHeaders2()
|
|
2195
|
+
},
|
|
2196
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99940)(), method: "ping" }),
|
|
2197
|
+
signal: AbortSignal.timeout(timeout)
|
|
2198
|
+
});
|
|
2199
|
+
const text = await res.body.text();
|
|
2200
|
+
const rawCtSse = res.headers["content-type"];
|
|
2201
|
+
const ct = (Array.isArray(rawCtSse) ? rawCtSse[0] : rawCtSse || "").toLowerCase();
|
|
2202
|
+
if (!ct.includes("text/event-stream")) {
|
|
2203
|
+
return { passed: true, details: "Server responded with JSON (not SSE) \u2014 event field check not applicable" };
|
|
2204
|
+
}
|
|
2205
|
+
const hasEventMessage = /^event:\s*message\s*$/m.test(text);
|
|
2206
|
+
if (hasEventMessage) {
|
|
2207
|
+
return { passed: true, details: "SSE response includes required event: message field" };
|
|
2208
|
+
}
|
|
2209
|
+
if (text.includes("data:")) {
|
|
2210
|
+
return {
|
|
2211
|
+
passed: false,
|
|
2212
|
+
details: "SSE response has data: fields but missing required event: message field (spec: MUST include event: message)"
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
return { passed: true, details: "SSE response empty or no data fields \u2014 check not applicable" };
|
|
2216
|
+
}
|
|
2217
|
+
);
|
|
2218
|
+
let cachedToolsList = null;
|
|
2219
|
+
await test(
|
|
2220
|
+
"tools-list",
|
|
2221
|
+
"tools/list returns valid response",
|
|
2222
|
+
"tools",
|
|
2223
|
+
hasTools,
|
|
2224
|
+
"server/tools#listing-tools",
|
|
2225
|
+
async () => {
|
|
2226
|
+
const res = await rpc("tools/list");
|
|
2227
|
+
const tools = res.body?.result?.tools;
|
|
2228
|
+
if (!Array.isArray(tools)) return { passed: false, details: "No tools array in result" };
|
|
2229
|
+
cachedToolsList = tools;
|
|
2230
|
+
toolCount = tools.length;
|
|
2231
|
+
toolNames = tools.map((t) => t.name).filter(Boolean);
|
|
2232
|
+
return {
|
|
2233
|
+
passed: true,
|
|
2234
|
+
details: `${toolCount} tool(s): ${toolNames.slice(0, 5).join(", ")}${toolCount > 5 ? "..." : ""}`
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
);
|
|
2238
|
+
const toolsListOk = cachedToolsList !== null;
|
|
2239
|
+
await test(
|
|
2240
|
+
"tools-schema",
|
|
2241
|
+
"All tools have name and inputSchema",
|
|
2242
|
+
"schema",
|
|
2243
|
+
hasTools,
|
|
2244
|
+
"server/tools#data-types",
|
|
2245
|
+
async () => {
|
|
2246
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
2247
|
+
const tools = cachedToolsList ?? [];
|
|
2248
|
+
const issues = [];
|
|
2249
|
+
for (const tool of tools) {
|
|
2250
|
+
if (!tool.name) {
|
|
2251
|
+
issues.push("Tool missing name");
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
if (tool.name.length > 128 || !/^[A-Za-z0-9_.\-]+$/.test(tool.name)) {
|
|
2255
|
+
issues.push(`${tool.name}: name format invalid`);
|
|
2256
|
+
}
|
|
2257
|
+
if (!tool.description) warnings.push(`Tool "${tool.name}" missing description`);
|
|
2258
|
+
if (!tool.inputSchema) {
|
|
2259
|
+
issues.push(`${tool.name}: missing inputSchema (required)`);
|
|
2260
|
+
} else if (typeof tool.inputSchema !== "object" || tool.inputSchema === null) {
|
|
2261
|
+
issues.push(`${tool.name}: inputSchema must be a valid JSON Schema object`);
|
|
2262
|
+
} else if (tool.inputSchema.type !== "object") {
|
|
2263
|
+
issues.push(
|
|
2264
|
+
`${tool.name}: inputSchema.type must be "object" (got "${tool.inputSchema.type || "undefined"}")`
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
const detail = issues.length === 0 ? "All tools have valid schemas" : issues.join("; ");
|
|
2269
|
+
return { passed: issues.length === 0, details: detail };
|
|
2270
|
+
}
|
|
2271
|
+
);
|
|
2272
|
+
await test(
|
|
2273
|
+
"tools-annotations",
|
|
2274
|
+
"Tool annotations are valid",
|
|
2275
|
+
"schema",
|
|
2276
|
+
false,
|
|
2277
|
+
"server/tools#annotations",
|
|
2278
|
+
async () => {
|
|
2279
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
2280
|
+
const tools = cachedToolsList ?? [];
|
|
2281
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
2282
|
+
const issues = [];
|
|
2283
|
+
let annotatedCount = 0;
|
|
2284
|
+
for (const tool of tools) {
|
|
2285
|
+
const ann = tool.annotations;
|
|
2286
|
+
if (!ann) continue;
|
|
2287
|
+
annotatedCount++;
|
|
2288
|
+
if (typeof ann !== "object" || ann === null) {
|
|
2289
|
+
issues.push(`${tool.name}: annotations must be an object`);
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
const boolFields = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"];
|
|
2293
|
+
for (const field of boolFields) {
|
|
2294
|
+
if (ann[field] !== void 0 && typeof ann[field] !== "boolean") {
|
|
2295
|
+
issues.push(`${tool.name}: annotations.${field} should be boolean, got ${typeof ann[field]}`);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2300
|
+
return {
|
|
2301
|
+
passed: true,
|
|
2302
|
+
details: annotatedCount > 0 ? `${annotatedCount} tool(s) with valid annotations` : "No tools have annotations (optional)"
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
);
|
|
2306
|
+
await test(
|
|
2307
|
+
"tools-title-field",
|
|
2308
|
+
"Tools include title field",
|
|
2309
|
+
"schema",
|
|
2310
|
+
false,
|
|
2311
|
+
"server/tools#data-types",
|
|
2312
|
+
async () => {
|
|
2313
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
2314
|
+
const tools = cachedToolsList ?? [];
|
|
2315
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
2316
|
+
const withTitle = tools.filter((t) => typeof t.title === "string");
|
|
2317
|
+
const issues = [];
|
|
2318
|
+
for (const tool of tools) {
|
|
2319
|
+
if (tool.title !== void 0 && typeof tool.title !== "string") {
|
|
2320
|
+
issues.push(`${tool.name}: title should be a string`);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2324
|
+
if (withTitle.length === 0) {
|
|
2325
|
+
return { passed: true, details: "No tools have title field (optional, added in 2025-11-25)" };
|
|
2326
|
+
}
|
|
2327
|
+
return { passed: true, details: `${withTitle.length}/${tools.length} tool(s) have title field` };
|
|
2328
|
+
}
|
|
2329
|
+
);
|
|
2330
|
+
await test(
|
|
2331
|
+
"tools-output-schema",
|
|
2332
|
+
"Tools with outputSchema are valid",
|
|
2333
|
+
"schema",
|
|
2334
|
+
false,
|
|
2335
|
+
"server/tools#structured-content",
|
|
2336
|
+
async () => {
|
|
2337
|
+
if (!toolsListOk) return { passed: false, details: "Skipped: tools/list failed" };
|
|
2338
|
+
const tools = cachedToolsList ?? [];
|
|
2339
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
2340
|
+
const issues = [];
|
|
2341
|
+
let withSchema = 0;
|
|
2342
|
+
for (const tool of tools) {
|
|
2343
|
+
if (tool.outputSchema === void 0) continue;
|
|
2344
|
+
withSchema++;
|
|
2345
|
+
if (typeof tool.outputSchema !== "object" || tool.outputSchema === null) {
|
|
2346
|
+
issues.push(`${tool.name}: outputSchema must be a JSON Schema object`);
|
|
2347
|
+
} else if (tool.outputSchema.type !== "object") {
|
|
2348
|
+
issues.push(
|
|
2349
|
+
`${tool.name}: outputSchema.type must be "object" (got "${tool.outputSchema.type || "undefined"}")`
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2354
|
+
return {
|
|
2355
|
+
passed: true,
|
|
2356
|
+
details: withSchema > 0 ? `${withSchema} tool(s) with valid outputSchema` : "No tools have outputSchema (optional)"
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
);
|
|
2360
|
+
if (toolNames.length > 0) {
|
|
2361
|
+
await test(
|
|
2362
|
+
"tools-call",
|
|
2363
|
+
"tools/call responds correctly",
|
|
2364
|
+
"tools",
|
|
2365
|
+
false,
|
|
2366
|
+
"server/tools#calling-tools",
|
|
2367
|
+
async () => {
|
|
2368
|
+
const res = await rpc("tools/call", { name: toolNames[0], arguments: {} });
|
|
2369
|
+
const result = res.body?.result;
|
|
2370
|
+
const error = res.body?.error;
|
|
2371
|
+
if (error) {
|
|
2372
|
+
const code = error.code;
|
|
2373
|
+
if (code === -32602 || code === -32600) {
|
|
2374
|
+
return { passed: true, details: `Invalid params error (acceptable): code ${code}` };
|
|
2375
|
+
}
|
|
2376
|
+
return { passed: true, details: `Protocol error: code ${code} \u2014 ${error.message}` };
|
|
2377
|
+
}
|
|
2378
|
+
if (result?.content && Array.isArray(result.content)) {
|
|
2379
|
+
if (result.isError) {
|
|
2380
|
+
return { passed: true, details: "Tool returned execution error with content (valid)" };
|
|
2381
|
+
}
|
|
2382
|
+
const badItems = result.content.filter((c) => !c.type);
|
|
2383
|
+
if (badItems.length > 0)
|
|
2384
|
+
return { passed: false, details: `${badItems.length} content item(s) missing 'type' field` };
|
|
2385
|
+
return { passed: true, details: `Returned ${result.content.length} content item(s)` };
|
|
2386
|
+
}
|
|
2387
|
+
return { passed: false, details: "Response missing content array" };
|
|
2388
|
+
}
|
|
2389
|
+
);
|
|
2390
|
+
await test(
|
|
2391
|
+
"tools-content-types",
|
|
2392
|
+
"Tool content items have valid types",
|
|
2393
|
+
"tools",
|
|
2394
|
+
false,
|
|
2395
|
+
"server/tools#calling-tools",
|
|
2396
|
+
async () => {
|
|
2397
|
+
const res = await rpc("tools/call", { name: toolNames[0], arguments: {} });
|
|
2398
|
+
const result = res.body?.result;
|
|
2399
|
+
const error = res.body?.error;
|
|
2400
|
+
if (error) {
|
|
2401
|
+
return { passed: true, details: `Tool returned error (content types not applicable): code ${error.code}` };
|
|
2402
|
+
}
|
|
2403
|
+
const content = result?.content;
|
|
2404
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
2405
|
+
return { passed: true, details: "No content items to validate" };
|
|
2406
|
+
}
|
|
2407
|
+
const issues = [];
|
|
2408
|
+
const types = /* @__PURE__ */ new Set();
|
|
2409
|
+
for (const item of content) {
|
|
2410
|
+
if (!item.type) {
|
|
2411
|
+
issues.push("Content item missing type field");
|
|
2412
|
+
} else if (!VALID_CONTENT_TYPES.includes(item.type)) {
|
|
2413
|
+
issues.push(`Unknown content type: "${item.type}"`);
|
|
2414
|
+
} else {
|
|
2415
|
+
types.add(item.type);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2419
|
+
return { passed: true, details: `Content types: ${[...types].join(", ")}` };
|
|
2420
|
+
}
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
if (hasTools) {
|
|
2424
|
+
await test(
|
|
2425
|
+
"tools-pagination",
|
|
2426
|
+
"tools/list supports pagination",
|
|
2427
|
+
"tools",
|
|
2428
|
+
false,
|
|
2429
|
+
"server/tools#listing-tools",
|
|
2430
|
+
async () => {
|
|
2431
|
+
const res = await rpc("tools/list");
|
|
2432
|
+
const result = res.body?.result;
|
|
2433
|
+
if (!result) return { passed: false, details: "No result from tools/list" };
|
|
2434
|
+
if (!Array.isArray(result.tools)) return { passed: false, details: "No tools array" };
|
|
2435
|
+
if (result.nextCursor !== void 0) {
|
|
2436
|
+
if (typeof result.nextCursor !== "string") {
|
|
2437
|
+
return { passed: false, details: `nextCursor should be string, got ${typeof result.nextCursor}` };
|
|
2438
|
+
}
|
|
2439
|
+
const nextRes = await rpc("tools/list", { cursor: result.nextCursor });
|
|
2440
|
+
const nextResult = nextRes.body?.result;
|
|
2441
|
+
if (!nextResult || !Array.isArray(nextResult.tools)) {
|
|
2442
|
+
return { passed: false, details: "Next page failed to return tools array" };
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
passed: true,
|
|
2446
|
+
details: `Pagination works: page 1 had ${result.tools.length} tools, page 2 had ${nextResult.tools.length} tools`
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
return { passed: true, details: `${result.tools.length} tool(s), no nextCursor (single page)` };
|
|
2450
|
+
}
|
|
2451
|
+
);
|
|
2452
|
+
await test(
|
|
2453
|
+
"tools-call-unknown",
|
|
2454
|
+
"Returns error for unknown tool name",
|
|
2455
|
+
"errors",
|
|
2456
|
+
false,
|
|
2457
|
+
"server/tools#error-handling",
|
|
2458
|
+
async () => {
|
|
2459
|
+
const res = await rpc("tools/call", { name: "__nonexistent_tool_compliance_test__", arguments: {} });
|
|
2460
|
+
const error = res.body?.error;
|
|
2461
|
+
const isError = res.body?.result?.isError;
|
|
2462
|
+
if (error) return { passed: true, details: `Error code: ${error.code} \u2014 ${error.message}` };
|
|
2463
|
+
if (isError) return { passed: true, details: "Tool execution error with isError=true (valid)" };
|
|
2464
|
+
return { passed: false, details: "No error returned for nonexistent tool" };
|
|
2465
|
+
}
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
const resourcesCap = serverInfo.capabilities.resources;
|
|
2469
|
+
const hasSubscribe = !!(typeof resourcesCap === "object" && resourcesCap !== null && "subscribe" in resourcesCap && resourcesCap.subscribe);
|
|
2470
|
+
if (hasResources) {
|
|
2471
|
+
let cachedResourcesList = null;
|
|
2472
|
+
await test(
|
|
2473
|
+
"resources-list",
|
|
2474
|
+
"resources/list returns valid response",
|
|
2475
|
+
"resources",
|
|
2476
|
+
true,
|
|
2477
|
+
"server/resources#listing-resources",
|
|
2478
|
+
async () => {
|
|
2479
|
+
const res = await rpc("resources/list");
|
|
2480
|
+
const resources = res.body?.result?.resources;
|
|
2481
|
+
if (!Array.isArray(resources)) return { passed: false, details: "No resources array" };
|
|
2482
|
+
cachedResourcesList = resources;
|
|
2483
|
+
resourceCount = resources.length;
|
|
2484
|
+
resourceNames = resources.map((r) => r.name).filter(Boolean);
|
|
2485
|
+
return { passed: true, details: `${resourceCount} resource(s)` };
|
|
2486
|
+
}
|
|
2487
|
+
);
|
|
2488
|
+
const resourcesListOk = cachedResourcesList !== null;
|
|
2489
|
+
await test(
|
|
2490
|
+
"resources-schema",
|
|
2491
|
+
"Resources have uri and name",
|
|
2492
|
+
"schema",
|
|
2493
|
+
true,
|
|
2494
|
+
"server/resources#data-types",
|
|
2495
|
+
async () => {
|
|
2496
|
+
if (!resourcesListOk) return { passed: false, details: "Skipped: resources/list failed" };
|
|
2497
|
+
const resources = cachedResourcesList ?? [];
|
|
2498
|
+
const issues = [];
|
|
2499
|
+
for (const r of resources) {
|
|
2500
|
+
if (!r.uri) issues.push("Resource missing uri");
|
|
2501
|
+
else {
|
|
2502
|
+
try {
|
|
2503
|
+
new URL(r.uri);
|
|
2504
|
+
} catch {
|
|
2505
|
+
issues.push(`${r.uri}: invalid URI format`);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
if (!r.name) issues.push(`${r.uri || "?"}: missing name`);
|
|
2509
|
+
if (!r.description) warnings.push(`Resource "${r.name || r.uri}" missing description`);
|
|
2510
|
+
if (!r.mimeType) warnings.push(`Resource "${r.name || r.uri}" missing mimeType`);
|
|
2511
|
+
}
|
|
2512
|
+
return {
|
|
2513
|
+
passed: issues.length === 0,
|
|
2514
|
+
details: issues.length === 0 ? "All resources valid" : issues.join("; ")
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
);
|
|
2518
|
+
if (resourceCount > 0) {
|
|
2519
|
+
await test(
|
|
2520
|
+
"resources-read",
|
|
2521
|
+
"resources/read returns content",
|
|
2522
|
+
"resources",
|
|
2523
|
+
false,
|
|
2524
|
+
"server/resources#reading-resources",
|
|
2525
|
+
async () => {
|
|
2526
|
+
const resources = cachedResourcesList ?? [];
|
|
2527
|
+
const firstUri = resources[0]?.uri;
|
|
2528
|
+
if (!firstUri) return { passed: false, details: "No resource URI to test" };
|
|
2529
|
+
const readRes = await rpc("resources/read", { uri: firstUri });
|
|
2530
|
+
const contents = readRes.body?.result?.contents;
|
|
2531
|
+
if (!Array.isArray(contents)) return { passed: false, details: "No contents array" };
|
|
2532
|
+
const issues = [];
|
|
2533
|
+
for (const c of contents) {
|
|
2534
|
+
if (!c.uri) issues.push("Content item missing uri");
|
|
2535
|
+
if (!c.text && !c.blob) issues.push(`Content item for ${c.uri || "?"} missing both text and blob`);
|
|
2536
|
+
}
|
|
2537
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2538
|
+
return { passed: true, details: `Read ${contents.length} content item(s) from ${firstUri}` };
|
|
2539
|
+
}
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
await test(
|
|
2543
|
+
"resources-templates",
|
|
2544
|
+
"resources/templates/list returns valid response",
|
|
2545
|
+
"resources",
|
|
2546
|
+
false,
|
|
2547
|
+
"server/resources#resource-templates",
|
|
2548
|
+
async () => {
|
|
2549
|
+
const res = await rpc("resources/templates/list");
|
|
2550
|
+
const error = res.body?.error;
|
|
2551
|
+
if (error) {
|
|
2552
|
+
if (error.code === -32601) return { passed: true, details: "Method not supported (acceptable)" };
|
|
2553
|
+
return { passed: false, details: `Error: ${error.message}` };
|
|
2554
|
+
}
|
|
2555
|
+
const templates = res.body?.result?.resourceTemplates;
|
|
2556
|
+
if (!Array.isArray(templates)) return { passed: false, details: "No resourceTemplates array" };
|
|
2557
|
+
const issues = [];
|
|
2558
|
+
for (const t of templates) {
|
|
2559
|
+
if (!t.uriTemplate) {
|
|
2560
|
+
issues.push("Template missing uriTemplate");
|
|
2561
|
+
} else if (typeof t.uriTemplate !== "string") {
|
|
2562
|
+
issues.push(`uriTemplate should be a string, got ${typeof t.uriTemplate}`);
|
|
2563
|
+
} else if (!t.uriTemplate.includes("{") || !t.uriTemplate.includes("}")) {
|
|
2564
|
+
warnings.push(`Template "${t.name || t.uriTemplate}" has no URI template parameters (e.g., {id})`);
|
|
2565
|
+
}
|
|
2566
|
+
if (!t.name) issues.push(`${t.uriTemplate || "?"}: missing name`);
|
|
2567
|
+
if (!t.description) warnings.push(`Template "${t.name || t.uriTemplate || "?"}" missing description`);
|
|
2568
|
+
}
|
|
2569
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2570
|
+
return { passed: true, details: `${templates.length} resource template(s)` };
|
|
2571
|
+
}
|
|
2572
|
+
);
|
|
2573
|
+
await test(
|
|
2574
|
+
"resources-pagination",
|
|
2575
|
+
"resources/list supports pagination",
|
|
2576
|
+
"resources",
|
|
2577
|
+
false,
|
|
2578
|
+
"server/resources#listing-resources",
|
|
2579
|
+
async () => {
|
|
2580
|
+
const res = await rpc("resources/list");
|
|
2581
|
+
const result = res.body?.result;
|
|
2582
|
+
if (!result) return { passed: false, details: "No result from resources/list" };
|
|
2583
|
+
if (!Array.isArray(result.resources)) return { passed: false, details: "No resources array" };
|
|
2584
|
+
if (result.nextCursor !== void 0) {
|
|
2585
|
+
if (typeof result.nextCursor !== "string") {
|
|
2586
|
+
return { passed: false, details: `nextCursor should be string, got ${typeof result.nextCursor}` };
|
|
2587
|
+
}
|
|
2588
|
+
const nextRes = await rpc("resources/list", { cursor: result.nextCursor });
|
|
2589
|
+
const nextResult = nextRes.body?.result;
|
|
2590
|
+
if (!nextResult || !Array.isArray(nextResult.resources)) {
|
|
2591
|
+
return { passed: false, details: "Next page failed to return resources array" };
|
|
2592
|
+
}
|
|
2593
|
+
return {
|
|
2594
|
+
passed: true,
|
|
2595
|
+
details: `Pagination works: page 1 had ${result.resources.length}, page 2 had ${nextResult.resources.length}`
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
return { passed: true, details: `${result.resources.length} resource(s), no nextCursor (single page)` };
|
|
2599
|
+
}
|
|
2600
|
+
);
|
|
2601
|
+
if (hasSubscribe && resourceCount > 0) {
|
|
2602
|
+
await test(
|
|
2603
|
+
"resources-subscribe",
|
|
2604
|
+
"Resource subscribe/unsubscribe",
|
|
2605
|
+
"resources",
|
|
2606
|
+
true,
|
|
2607
|
+
"server/resources#subscriptions",
|
|
2608
|
+
async () => {
|
|
2609
|
+
const resources = cachedResourcesList ?? [];
|
|
2610
|
+
const firstUri = resources[0]?.uri;
|
|
2611
|
+
if (!firstUri) return { passed: false, details: "No resource URI for subscribe test" };
|
|
2612
|
+
const subRes = await rpc("resources/subscribe", { uri: firstUri });
|
|
2613
|
+
if (subRes.body?.error) {
|
|
2614
|
+
return {
|
|
2615
|
+
passed: false,
|
|
2616
|
+
details: `Subscribe error: ${subRes.body.error.code} \u2014 ${subRes.body.error.message}`
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
const unsubRes = await rpc("resources/unsubscribe", { uri: firstUri });
|
|
2620
|
+
if (unsubRes.body?.error) {
|
|
2621
|
+
return {
|
|
2622
|
+
passed: false,
|
|
2623
|
+
details: `Unsubscribe error: ${unsubRes.body.error.code} \u2014 ${unsubRes.body.error.message}`
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
return { passed: true, details: `Subscribe/unsubscribe for ${firstUri} succeeded` };
|
|
2627
|
+
}
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
if (hasPrompts) {
|
|
2632
|
+
let cachedPromptsList = null;
|
|
2633
|
+
await test(
|
|
2634
|
+
"prompts-list",
|
|
2635
|
+
"prompts/list returns valid response",
|
|
2636
|
+
"prompts",
|
|
2637
|
+
true,
|
|
2638
|
+
"server/prompts#listing-prompts",
|
|
2639
|
+
async () => {
|
|
2640
|
+
const res = await rpc("prompts/list");
|
|
2641
|
+
const prompts = res.body?.result?.prompts;
|
|
2642
|
+
if (!Array.isArray(prompts)) return { passed: false, details: "No prompts array" };
|
|
2643
|
+
cachedPromptsList = prompts;
|
|
2644
|
+
promptCount = prompts.length;
|
|
2645
|
+
promptNames = prompts.map((p) => p.name).filter(Boolean);
|
|
2646
|
+
return {
|
|
2647
|
+
passed: true,
|
|
2648
|
+
details: `${promptCount} prompt(s): ${promptNames.slice(0, 5).join(", ")}${promptCount > 5 ? "..." : ""}`
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
);
|
|
2652
|
+
const promptsListOk = cachedPromptsList !== null;
|
|
2653
|
+
await test("prompts-schema", "Prompts have name field", "schema", true, "server/prompts#data-types", async () => {
|
|
2654
|
+
if (!promptsListOk) return { passed: false, details: "Skipped: prompts/list failed" };
|
|
2655
|
+
const prompts = cachedPromptsList ?? [];
|
|
2656
|
+
const issues = [];
|
|
2657
|
+
for (const p of prompts) {
|
|
2658
|
+
if (!p.name) issues.push("Prompt missing name");
|
|
2659
|
+
if (!p.description) warnings.push(`Prompt "${p.name || "?"}" missing description`);
|
|
2660
|
+
if (p.arguments && !Array.isArray(p.arguments)) issues.push(`${p.name || "?"}: arguments must be an array`);
|
|
2661
|
+
if (Array.isArray(p.arguments)) {
|
|
2662
|
+
for (const arg of p.arguments) {
|
|
2663
|
+
if (!arg.name) issues.push(`${p.name}: argument missing name`);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
return { passed: issues.length === 0, details: issues.length === 0 ? "All prompts valid" : issues.join("; ") };
|
|
2668
|
+
});
|
|
2669
|
+
if (promptNames.length > 0) {
|
|
2670
|
+
await test(
|
|
2671
|
+
"prompts-get",
|
|
2672
|
+
"prompts/get returns valid messages",
|
|
2673
|
+
"prompts",
|
|
2674
|
+
false,
|
|
2675
|
+
"server/prompts#getting-a-prompt",
|
|
2676
|
+
async () => {
|
|
2677
|
+
const res = await rpc("prompts/get", { name: promptNames[0] });
|
|
2678
|
+
const error = res.body?.error;
|
|
2679
|
+
if (error) return { passed: true, details: `Error (may need arguments): code ${error.code}` };
|
|
2680
|
+
const messages = res.body?.result?.messages;
|
|
2681
|
+
if (!Array.isArray(messages)) return { passed: false, details: "No messages array in result" };
|
|
2682
|
+
const issues = [];
|
|
2683
|
+
for (const msg of messages) {
|
|
2684
|
+
if (!msg.role || !["user", "assistant"].includes(msg.role)) issues.push(`Invalid role: ${msg.role}`);
|
|
2685
|
+
if (!msg.content) issues.push("Message missing content");
|
|
2686
|
+
}
|
|
2687
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2688
|
+
return { passed: true, details: `${messages.length} message(s) from ${promptNames[0]}` };
|
|
2689
|
+
}
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
await test(
|
|
2693
|
+
"prompts-pagination",
|
|
2694
|
+
"prompts/list supports pagination",
|
|
2695
|
+
"prompts",
|
|
2696
|
+
false,
|
|
2697
|
+
"server/prompts#listing-prompts",
|
|
2698
|
+
async () => {
|
|
2699
|
+
const res = await rpc("prompts/list");
|
|
2700
|
+
const result = res.body?.result;
|
|
2701
|
+
if (!result) return { passed: false, details: "No result from prompts/list" };
|
|
2702
|
+
if (!Array.isArray(result.prompts)) return { passed: false, details: "No prompts array" };
|
|
2703
|
+
if (result.nextCursor !== void 0) {
|
|
2704
|
+
if (typeof result.nextCursor !== "string") {
|
|
2705
|
+
return { passed: false, details: `nextCursor should be string, got ${typeof result.nextCursor}` };
|
|
2706
|
+
}
|
|
2707
|
+
const nextRes = await rpc("prompts/list", { cursor: result.nextCursor });
|
|
2708
|
+
const nextResult = nextRes.body?.result;
|
|
2709
|
+
if (!nextResult || !Array.isArray(nextResult.prompts)) {
|
|
2710
|
+
return { passed: false, details: "Next page failed to return prompts array" };
|
|
2711
|
+
}
|
|
2712
|
+
return {
|
|
2713
|
+
passed: true,
|
|
2714
|
+
details: `Pagination works: page 1 had ${result.prompts.length}, page 2 had ${nextResult.prompts.length}`
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
return { passed: true, details: `${result.prompts.length} prompt(s), no nextCursor (single page)` };
|
|
2718
|
+
}
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
await test(
|
|
2722
|
+
"error-unknown-method",
|
|
2723
|
+
"Returns JSON-RPC error for unknown method",
|
|
2724
|
+
"errors",
|
|
2725
|
+
true,
|
|
2726
|
+
"basic",
|
|
2727
|
+
async () => {
|
|
2728
|
+
const res = await rpc("nonexistent/method");
|
|
2729
|
+
const error = res.body?.error;
|
|
2730
|
+
if (!error) return { passed: false, details: "No JSON-RPC error returned for unknown method" };
|
|
2731
|
+
const correctCode = error.code === -32601;
|
|
2732
|
+
return {
|
|
2733
|
+
passed: true,
|
|
2734
|
+
details: `Error code: ${error.code}${correctCode ? " (correct: Method not found)" : " (expected -32601)"} \u2014 ${error.message}`
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
);
|
|
2738
|
+
await test(
|
|
2739
|
+
"error-method-code",
|
|
2740
|
+
"Uses correct JSON-RPC error code for unknown method",
|
|
2741
|
+
"errors",
|
|
2742
|
+
false,
|
|
2743
|
+
"basic",
|
|
2744
|
+
async () => {
|
|
2745
|
+
const res = await rpc("nonexistent/method");
|
|
2746
|
+
const error = res.body?.error;
|
|
2747
|
+
if (!error) return { passed: false, details: "No error returned" };
|
|
2748
|
+
return { passed: error.code === -32601, details: `Expected -32601, got ${error.code}` };
|
|
2749
|
+
}
|
|
2750
|
+
);
|
|
2751
|
+
await test("error-invalid-jsonrpc", "Handles malformed JSON-RPC", "errors", true, "basic", async () => {
|
|
2752
|
+
const res = await request2(backendUrl, {
|
|
2753
|
+
method: "POST",
|
|
2754
|
+
headers: {
|
|
2755
|
+
"Content-Type": "application/json",
|
|
2756
|
+
Accept: "application/json, text/event-stream",
|
|
2757
|
+
...buildHeaders2()
|
|
2758
|
+
},
|
|
2759
|
+
body: JSON.stringify({ not: "a valid jsonrpc message" }),
|
|
2760
|
+
signal: AbortSignal.timeout(timeout)
|
|
2761
|
+
});
|
|
2762
|
+
const text = await res.body.text();
|
|
2763
|
+
try {
|
|
2764
|
+
const body = JSON.parse(text);
|
|
2765
|
+
if (body?.error) {
|
|
2766
|
+
const correctCode = body.error.code === -32600;
|
|
2767
|
+
return {
|
|
2768
|
+
passed: true,
|
|
2769
|
+
details: `Error code: ${body.error.code}${correctCode ? " (correct: Invalid Request)" : ""} \u2014 ${body.error.message}`
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
} catch {
|
|
2773
|
+
}
|
|
2774
|
+
if (res.statusCode >= 400 && res.statusCode < 500)
|
|
2775
|
+
return { passed: true, details: `HTTP ${res.statusCode} (acceptable)` };
|
|
2776
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected JSON-RPC error or 4xx status` };
|
|
2777
|
+
});
|
|
2778
|
+
await test("error-invalid-json", "Handles invalid JSON body", "errors", false, "basic", async () => {
|
|
2779
|
+
const res = await request2(backendUrl, {
|
|
2780
|
+
method: "POST",
|
|
2781
|
+
headers: {
|
|
2782
|
+
"Content-Type": "application/json",
|
|
2783
|
+
Accept: "application/json, text/event-stream",
|
|
2784
|
+
...buildHeaders2()
|
|
2785
|
+
},
|
|
2786
|
+
body: "{this is not valid json!!!",
|
|
2787
|
+
signal: AbortSignal.timeout(timeout)
|
|
2788
|
+
});
|
|
2789
|
+
const text = await res.body.text();
|
|
2790
|
+
try {
|
|
2791
|
+
const body = JSON.parse(text);
|
|
2792
|
+
if (body?.error) return { passed: true, details: `Error code: ${body.error.code} \u2014 ${body.error.message}` };
|
|
2793
|
+
} catch {
|
|
2794
|
+
}
|
|
2795
|
+
if (res.statusCode >= 400 && res.statusCode < 500)
|
|
2796
|
+
return { passed: true, details: `HTTP ${res.statusCode} (acceptable)` };
|
|
2797
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected parse error or 4xx status` };
|
|
2798
|
+
});
|
|
2799
|
+
await test(
|
|
2800
|
+
"error-missing-params",
|
|
2801
|
+
"Returns error for tools/call without name",
|
|
2802
|
+
"errors",
|
|
2803
|
+
false,
|
|
2804
|
+
"server/tools#error-handling",
|
|
2805
|
+
async () => {
|
|
2806
|
+
const res = await rpc("tools/call", {});
|
|
2807
|
+
const error = res.body?.error;
|
|
2808
|
+
const isError = res.body?.result?.isError;
|
|
2809
|
+
if (error) {
|
|
2810
|
+
const correctCode = error.code === -32602;
|
|
2811
|
+
return {
|
|
2812
|
+
passed: true,
|
|
2813
|
+
details: `Error code: ${error.code}${correctCode ? " (correct: Invalid params)" : ""} \u2014 ${error.message}`
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
if (isError) return { passed: true, details: "Tool execution error (valid)" };
|
|
2817
|
+
return { passed: false, details: "No error for tools/call without name" };
|
|
2818
|
+
}
|
|
2819
|
+
);
|
|
2820
|
+
await test("error-parse-code", "Returns -32700 for invalid JSON", "errors", false, "basic", async () => {
|
|
2821
|
+
const res = await request2(backendUrl, {
|
|
2822
|
+
method: "POST",
|
|
2823
|
+
headers: {
|
|
2824
|
+
"Content-Type": "application/json",
|
|
2825
|
+
Accept: "application/json, text/event-stream",
|
|
2826
|
+
...buildHeaders2()
|
|
2827
|
+
},
|
|
2828
|
+
body: "<<<not json>>>",
|
|
2829
|
+
signal: AbortSignal.timeout(timeout)
|
|
2830
|
+
});
|
|
2831
|
+
const text = await res.body.text();
|
|
2832
|
+
try {
|
|
2833
|
+
const body = JSON.parse(text);
|
|
2834
|
+
if (body?.error?.code === -32700) {
|
|
2835
|
+
return { passed: true, details: `Error code: -32700 (Parse error) \u2014 ${body.error.message}` };
|
|
2836
|
+
}
|
|
2837
|
+
if (body?.error) {
|
|
2838
|
+
return { passed: false, details: `Expected -32700, got ${body.error.code} \u2014 ${body.error.message}` };
|
|
2839
|
+
}
|
|
2840
|
+
} catch {
|
|
2841
|
+
}
|
|
2842
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
2843
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should return JSON-RPC error code -32700` };
|
|
2844
|
+
}
|
|
2845
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32700` };
|
|
2846
|
+
});
|
|
2847
|
+
await test(
|
|
2848
|
+
"error-invalid-request-code",
|
|
2849
|
+
"Returns -32600 for invalid request",
|
|
2850
|
+
"errors",
|
|
2851
|
+
false,
|
|
2852
|
+
"basic",
|
|
2853
|
+
async () => {
|
|
2854
|
+
const res = await request2(backendUrl, {
|
|
2855
|
+
method: "POST",
|
|
2856
|
+
headers: {
|
|
2857
|
+
"Content-Type": "application/json",
|
|
2858
|
+
Accept: "application/json, text/event-stream",
|
|
2859
|
+
...buildHeaders2()
|
|
2860
|
+
},
|
|
2861
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99999 }),
|
|
2862
|
+
signal: AbortSignal.timeout(timeout)
|
|
2863
|
+
});
|
|
2864
|
+
const text = await res.body.text();
|
|
2865
|
+
try {
|
|
2866
|
+
const body = JSON.parse(text);
|
|
2867
|
+
if (body?.error?.code === -32600) {
|
|
2868
|
+
return { passed: true, details: `Error code: -32600 (Invalid Request) \u2014 ${body.error.message}` };
|
|
2869
|
+
}
|
|
2870
|
+
if (body?.error) {
|
|
2871
|
+
return { passed: false, details: `Expected -32600, got ${body.error.code} \u2014 ${body.error.message}` };
|
|
2872
|
+
}
|
|
2873
|
+
} catch {
|
|
2874
|
+
}
|
|
2875
|
+
if (res.statusCode >= 400 && res.statusCode < 500) {
|
|
2876
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server should return JSON-RPC error code -32600` };
|
|
2877
|
+
}
|
|
2878
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32600` };
|
|
2879
|
+
}
|
|
2880
|
+
);
|
|
2881
|
+
const undeclaredMethods = [
|
|
2882
|
+
{ method: "tools/list", capability: "tools", declared: hasTools },
|
|
2883
|
+
{ method: "resources/list", capability: "resources", declared: hasResources },
|
|
2884
|
+
{ method: "prompts/list", capability: "prompts", declared: hasPrompts }
|
|
2885
|
+
];
|
|
2886
|
+
const undeclared = undeclaredMethods.filter((m) => !m.declared);
|
|
2887
|
+
await test(
|
|
2888
|
+
"error-capability-gated",
|
|
2889
|
+
"Rejects methods for undeclared capabilities",
|
|
2890
|
+
"errors",
|
|
2891
|
+
false,
|
|
2892
|
+
"basic/lifecycle#capability-negotiation",
|
|
2893
|
+
async () => {
|
|
2894
|
+
if (undeclared.length === 0) {
|
|
2895
|
+
return {
|
|
2896
|
+
passed: true,
|
|
2897
|
+
details: "Server declares all capabilities (tools, resources, prompts) \u2014 no undeclared methods to test"
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
const issues = [];
|
|
2901
|
+
for (const { method, capability } of undeclared) {
|
|
2902
|
+
const res = await rpc(method);
|
|
2903
|
+
const error = res.body?.error;
|
|
2904
|
+
if (!error && res.body?.result) {
|
|
2905
|
+
issues.push(`${method} returned success despite missing ${capability} capability`);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
2909
|
+
return {
|
|
2910
|
+
passed: true,
|
|
2911
|
+
details: `Tested ${undeclared.length} undeclared method(s): ${undeclared.map((m) => m.method).join(", ")} \u2014 all returned errors`
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
);
|
|
2915
|
+
const listMethodForCursor = hasTools ? "tools/list" : hasResources ? "resources/list" : hasPrompts ? "prompts/list" : null;
|
|
2916
|
+
await test(
|
|
2917
|
+
"error-invalid-cursor",
|
|
2918
|
+
"Handles invalid pagination cursor gracefully",
|
|
2919
|
+
"errors",
|
|
2920
|
+
false,
|
|
2921
|
+
"basic",
|
|
2922
|
+
async () => {
|
|
2923
|
+
if (!listMethodForCursor) {
|
|
2924
|
+
return { passed: true, details: "No list methods available to test (skipped)" };
|
|
2925
|
+
}
|
|
2926
|
+
const res = await rpc(listMethodForCursor, { cursor: "!!!invalid-garbage-cursor-$$$" });
|
|
2927
|
+
const error = res.body?.error;
|
|
2928
|
+
if (error) {
|
|
2929
|
+
return { passed: true, details: `Invalid cursor rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
2930
|
+
}
|
|
2931
|
+
const result = res.body?.result;
|
|
2932
|
+
if (result) {
|
|
2933
|
+
return { passed: true, details: "Server returned results (likely ignored invalid cursor)" };
|
|
2934
|
+
}
|
|
2935
|
+
return { passed: false, details: "No error or result for invalid cursor" };
|
|
2936
|
+
}
|
|
2937
|
+
);
|
|
2938
|
+
const hasAuth = !!userHeaders.Authorization || !!userHeaders.authorization;
|
|
2939
|
+
await test(
|
|
2940
|
+
"security-auth-required",
|
|
2941
|
+
"Rejects unauthenticated requests",
|
|
2942
|
+
"security",
|
|
2943
|
+
false,
|
|
2944
|
+
"basic/authorization",
|
|
2945
|
+
async () => {
|
|
2946
|
+
if (!hasAuth) {
|
|
2947
|
+
return {
|
|
2948
|
+
passed: false,
|
|
2949
|
+
details: "Server does not require auth (no --auth provided and server accepted unauthenticated requests)"
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
const noAuthHeaders = {};
|
|
2953
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
2954
|
+
try {
|
|
2955
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
2956
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
2957
|
+
return { passed: true, details: `HTTP ${res.statusCode} (unauthenticated request rejected)` };
|
|
2958
|
+
}
|
|
2959
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepted unauthenticated request` };
|
|
2960
|
+
} catch (err) {
|
|
2961
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
);
|
|
2965
|
+
await test(
|
|
2966
|
+
"security-www-authenticate",
|
|
2967
|
+
"401 responses include WWW-Authenticate header",
|
|
2968
|
+
"security",
|
|
2969
|
+
false,
|
|
2970
|
+
"basic/authorization",
|
|
2971
|
+
async () => {
|
|
2972
|
+
if (!hasAuth) {
|
|
2973
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
2974
|
+
}
|
|
2975
|
+
const noAuthHeaders = {};
|
|
2976
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
2977
|
+
try {
|
|
2978
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
2979
|
+
if (res.statusCode === 401) {
|
|
2980
|
+
const wwwAuth = res.headers["www-authenticate"];
|
|
2981
|
+
if (wwwAuth) {
|
|
2982
|
+
return { passed: true, details: `WWW-Authenticate: ${wwwAuth}` };
|
|
2983
|
+
}
|
|
2984
|
+
return {
|
|
2985
|
+
passed: false,
|
|
2986
|
+
details: "HTTP 401 but missing WWW-Authenticate header (spec: SHOULD include to indicate required auth scheme)"
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
if (res.statusCode === 403) {
|
|
2990
|
+
return { passed: true, details: "HTTP 403 (WWW-Authenticate not applicable for 403)" };
|
|
2991
|
+
}
|
|
2992
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 not a 401 response` };
|
|
2993
|
+
} catch {
|
|
2994
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
);
|
|
2998
|
+
await test(
|
|
2999
|
+
"security-auth-malformed",
|
|
3000
|
+
"Rejects malformed auth credentials",
|
|
3001
|
+
"security",
|
|
3002
|
+
false,
|
|
3003
|
+
"basic/authorization",
|
|
3004
|
+
async () => {
|
|
3005
|
+
if (!hasAuth) {
|
|
3006
|
+
return { passed: false, details: "Skipped: server does not require auth" };
|
|
3007
|
+
}
|
|
3008
|
+
const malformedHeaders = {
|
|
3009
|
+
Authorization: "Bearer INVALID_GARBAGE_TOKEN_!@#$%^&*()"
|
|
3010
|
+
};
|
|
3011
|
+
if (sessionId) malformedHeaders["mcp-session-id"] = sessionId;
|
|
3012
|
+
try {
|
|
3013
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, malformedHeaders, timeout);
|
|
3014
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
3015
|
+
return { passed: true, details: `HTTP ${res.statusCode} (malformed auth rejected)` };
|
|
3016
|
+
}
|
|
3017
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepted malformed auth token` };
|
|
3018
|
+
} catch (err) {
|
|
3019
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
);
|
|
3023
|
+
await test("security-tls-required", "Enforces HTTPS/TLS", "security", false, "basic/authorization", async () => {
|
|
3024
|
+
const parsedUrl = new URL(backendUrl);
|
|
3025
|
+
if (parsedUrl.protocol !== "https:") {
|
|
3026
|
+
return {
|
|
3027
|
+
passed: false,
|
|
3028
|
+
details: `Server URL uses ${parsedUrl.protocol} \u2014 production servers should use HTTPS`
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
const httpUrl = backendUrl.replace(/^https:/, "http:");
|
|
3032
|
+
try {
|
|
3033
|
+
const res = await request2(httpUrl, {
|
|
3034
|
+
method: "POST",
|
|
3035
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
3036
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 99950, method: "ping" }),
|
|
3037
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
3038
|
+
});
|
|
3039
|
+
await res.body.text();
|
|
3040
|
+
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 308) {
|
|
3041
|
+
return { passed: true, details: `HTTP ${res.statusCode} redirect to HTTPS (good)` };
|
|
3042
|
+
}
|
|
3043
|
+
if (res.statusCode >= 400) {
|
|
3044
|
+
return { passed: true, details: `HTTP ${res.statusCode} (plaintext rejected)` };
|
|
3045
|
+
}
|
|
3046
|
+
return { passed: false, details: `HTTP ${res.statusCode} \u2014 server accepts plaintext HTTP connections` };
|
|
3047
|
+
} catch {
|
|
3048
|
+
return { passed: true, details: "HTTP connection refused (HTTPS enforced)" };
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
await test(
|
|
3052
|
+
"security-session-entropy",
|
|
3053
|
+
"Session IDs are high-entropy",
|
|
3054
|
+
"security",
|
|
3055
|
+
false,
|
|
3056
|
+
"basic/transports#streamable-http",
|
|
3057
|
+
async () => {
|
|
3058
|
+
if (!sessionId) {
|
|
3059
|
+
return { passed: true, details: "Server does not issue session IDs (skipped)" };
|
|
3060
|
+
}
|
|
3061
|
+
if (sessionId.length < 16) {
|
|
3062
|
+
return {
|
|
3063
|
+
passed: false,
|
|
3064
|
+
details: `Session ID too short (${sessionId.length} chars): "${sessionId}" \u2014 should be \u226516 chars`
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
if (/^\d+$/.test(sessionId)) {
|
|
3068
|
+
return {
|
|
3069
|
+
passed: false,
|
|
3070
|
+
details: `Session ID is purely numeric: "${sessionId}" \u2014 likely sequential, not random`
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
const uniqueChars = new Set(sessionId.toLowerCase()).size;
|
|
3074
|
+
if (uniqueChars < 8) {
|
|
3075
|
+
return {
|
|
3076
|
+
passed: false,
|
|
3077
|
+
details: `Session ID has low character diversity (${uniqueChars} unique chars): "${sessionId}"`
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
return {
|
|
3081
|
+
passed: true,
|
|
3082
|
+
details: `Session ID has good entropy (${sessionId.length} chars, ${uniqueChars} unique): "${sessionId.substring(0, 16)}..."`
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
);
|
|
3086
|
+
await test(
|
|
3087
|
+
"security-session-not-auth",
|
|
3088
|
+
"Session ID does not bypass auth",
|
|
3089
|
+
"security",
|
|
3090
|
+
false,
|
|
3091
|
+
"basic/transports#streamable-http",
|
|
3092
|
+
async () => {
|
|
3093
|
+
if (!hasAuth) {
|
|
3094
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
3095
|
+
}
|
|
3096
|
+
if (!sessionId) {
|
|
3097
|
+
return { passed: true, details: "Skipped: server does not issue session IDs" };
|
|
3098
|
+
}
|
|
3099
|
+
const sessionOnlyHeaders = {
|
|
3100
|
+
"mcp-session-id": sessionId
|
|
3101
|
+
};
|
|
3102
|
+
try {
|
|
3103
|
+
const res = await mcpRequest(backendUrl, "ping", void 0, nextId, sessionOnlyHeaders, timeout);
|
|
3104
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
3105
|
+
return { passed: true, details: `HTTP ${res.statusCode} (session ID alone not sufficient for auth)` };
|
|
3106
|
+
}
|
|
3107
|
+
return {
|
|
3108
|
+
passed: false,
|
|
3109
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted session ID without auth (spec: MUST NOT use sessions for authentication)`
|
|
3110
|
+
};
|
|
3111
|
+
} catch {
|
|
3112
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
);
|
|
3116
|
+
await test(
|
|
3117
|
+
"security-oauth-metadata",
|
|
3118
|
+
"Protected Resource Metadata endpoint exists",
|
|
3119
|
+
"security",
|
|
3120
|
+
false,
|
|
3121
|
+
"basic/authorization",
|
|
3122
|
+
async () => {
|
|
3123
|
+
if (!hasAuth) {
|
|
3124
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
3125
|
+
}
|
|
3126
|
+
const parsedUrl = new URL(backendUrl);
|
|
3127
|
+
const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
|
|
3128
|
+
try {
|
|
3129
|
+
const res = await request2(prmUrl, {
|
|
3130
|
+
method: "GET",
|
|
3131
|
+
headers: { Accept: "application/json" },
|
|
3132
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
3133
|
+
});
|
|
3134
|
+
const text = await res.body.text();
|
|
3135
|
+
if (res.statusCode === 200) {
|
|
3136
|
+
try {
|
|
3137
|
+
const meta = JSON.parse(text);
|
|
3138
|
+
if (!meta.resource) {
|
|
3139
|
+
return { passed: false, details: "PRM response missing required 'resource' field" };
|
|
3140
|
+
}
|
|
3141
|
+
if (!Array.isArray(meta.authorization_servers) || meta.authorization_servers.length === 0) {
|
|
3142
|
+
return { passed: false, details: "PRM response missing 'authorization_servers' array" };
|
|
3143
|
+
}
|
|
3144
|
+
return {
|
|
3145
|
+
passed: true,
|
|
3146
|
+
details: `Protected Resource Metadata found: resource=${meta.resource}, ${meta.authorization_servers.length} auth server(s)`
|
|
3147
|
+
};
|
|
3148
|
+
} catch {
|
|
3149
|
+
return { passed: false, details: "PRM endpoint returned non-JSON response" };
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
|
|
3153
|
+
try {
|
|
3154
|
+
const legacyRes = await request2(legacyUrl, {
|
|
3155
|
+
method: "GET",
|
|
3156
|
+
headers: { Accept: "application/json" },
|
|
3157
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
3158
|
+
});
|
|
3159
|
+
const legacyText = await legacyRes.body.text();
|
|
3160
|
+
if (legacyRes.statusCode === 200) {
|
|
3161
|
+
try {
|
|
3162
|
+
const legacyMeta = JSON.parse(legacyText);
|
|
3163
|
+
if (legacyMeta.issuer && legacyMeta.token_endpoint) {
|
|
3164
|
+
warnings.push(
|
|
3165
|
+
"Server uses legacy /.well-known/oauth-authorization-server instead of /.well-known/oauth-protected-resource (RFC 9728). Update to PRM for 2025-11-25 compliance."
|
|
3166
|
+
);
|
|
3167
|
+
return {
|
|
3168
|
+
passed: true,
|
|
3169
|
+
details: `Legacy OAuth AS metadata found: issuer=${legacyMeta.issuer} (should migrate to PRM)`
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
} catch {
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
} catch {
|
|
3176
|
+
}
|
|
3177
|
+
return {
|
|
3178
|
+
passed: false,
|
|
3179
|
+
details: `PRM endpoint returned HTTP ${res.statusCode} and no legacy OAuth metadata found`
|
|
3180
|
+
};
|
|
3181
|
+
} catch {
|
|
3182
|
+
return { passed: false, details: "PRM endpoint unreachable" };
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
);
|
|
3186
|
+
await test(
|
|
3187
|
+
"security-token-in-uri",
|
|
3188
|
+
"Rejects auth tokens in query string",
|
|
3189
|
+
"security",
|
|
3190
|
+
false,
|
|
3191
|
+
"basic/authorization",
|
|
3192
|
+
async () => {
|
|
3193
|
+
if (!hasAuth) {
|
|
3194
|
+
return { passed: true, details: "Skipped: server does not require auth" };
|
|
3195
|
+
}
|
|
3196
|
+
const authValue = userHeaders.Authorization || userHeaders.authorization || "";
|
|
3197
|
+
const token = authValue.replace(/^Bearer\s+/i, "");
|
|
3198
|
+
if (!token) {
|
|
3199
|
+
return { passed: true, details: "Skipped: could not extract token from auth header" };
|
|
3200
|
+
}
|
|
3201
|
+
const uriWithToken = `${backendUrl}${backendUrl.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
|
|
3202
|
+
try {
|
|
3203
|
+
const noAuthHeaders = {};
|
|
3204
|
+
if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
|
|
3205
|
+
const res = await mcpRequest(uriWithToken, "ping", void 0, nextId, noAuthHeaders, timeout);
|
|
3206
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
3207
|
+
return { passed: true, details: `HTTP ${res.statusCode} (token in query string rejected)` };
|
|
3208
|
+
}
|
|
3209
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
3210
|
+
return {
|
|
3211
|
+
passed: false,
|
|
3212
|
+
details: "Server accepted auth token in query string (spec: MUST NOT transmit credentials in URIs)"
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
return { passed: true, details: `HTTP ${res.statusCode} (token in query string not accepted)` };
|
|
3216
|
+
} catch {
|
|
3217
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
);
|
|
3221
|
+
await test(
|
|
3222
|
+
"security-cors-headers",
|
|
3223
|
+
"CORS headers are restrictive",
|
|
3224
|
+
"security",
|
|
3225
|
+
false,
|
|
3226
|
+
"basic/transports#streamable-http",
|
|
3227
|
+
async () => {
|
|
3228
|
+
try {
|
|
3229
|
+
const res = await request2(backendUrl, {
|
|
3230
|
+
method: "OPTIONS",
|
|
3231
|
+
headers: {
|
|
3232
|
+
Origin: "https://evil.example.com",
|
|
3233
|
+
"Access-Control-Request-Method": "POST",
|
|
3234
|
+
...buildHeaders2()
|
|
3235
|
+
},
|
|
3236
|
+
signal: AbortSignal.timeout(Math.min(timeout, 5e3))
|
|
3237
|
+
});
|
|
3238
|
+
await res.body.text();
|
|
3239
|
+
const acao = res.headers["access-control-allow-origin"];
|
|
3240
|
+
if (!acao) {
|
|
3241
|
+
return { passed: true, details: "No CORS headers returned (server-to-server only, acceptable)" };
|
|
3242
|
+
}
|
|
3243
|
+
if (acao === "*") {
|
|
3244
|
+
return {
|
|
3245
|
+
passed: false,
|
|
3246
|
+
details: 'Access-Control-Allow-Origin is "*" (wildcard) \u2014 allows cross-origin credential theft'
|
|
3247
|
+
};
|
|
3248
|
+
}
|
|
3249
|
+
if (acao === "https://evil.example.com") {
|
|
3250
|
+
return { passed: false, details: "Server reflects arbitrary Origin in CORS \u2014 effectively wildcard" };
|
|
3251
|
+
}
|
|
3252
|
+
return { passed: true, details: `CORS restricted to: ${acao}` };
|
|
3253
|
+
} catch {
|
|
3254
|
+
return { passed: true, details: "OPTIONS request failed (no CORS, acceptable)" };
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
);
|
|
3258
|
+
await test(
|
|
3259
|
+
"security-origin-validation",
|
|
3260
|
+
"Validates Origin header on requests",
|
|
3261
|
+
"security",
|
|
3262
|
+
false,
|
|
3263
|
+
"basic/transports#streamable-http",
|
|
3264
|
+
async () => {
|
|
3265
|
+
try {
|
|
3266
|
+
const res = await request2(backendUrl, {
|
|
3267
|
+
method: "POST",
|
|
3268
|
+
headers: {
|
|
3269
|
+
"Content-Type": "application/json",
|
|
3270
|
+
Accept: "application/json, text/event-stream",
|
|
3271
|
+
Origin: "https://evil-rebinding-attack.example.com",
|
|
3272
|
+
...buildHeaders2()
|
|
3273
|
+
},
|
|
3274
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: createIdCounter(99970)(), method: "ping" }),
|
|
3275
|
+
signal: AbortSignal.timeout(timeout)
|
|
3276
|
+
});
|
|
3277
|
+
await res.body.text();
|
|
3278
|
+
if (res.statusCode === 403 || res.statusCode === 401) {
|
|
3279
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
3280
|
+
}
|
|
3281
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
3282
|
+
return {
|
|
3283
|
+
passed: false,
|
|
3284
|
+
details: `HTTP ${res.statusCode} \u2014 server accepted request with untrusted Origin header (spec: MUST validate Origin for DNS rebinding protection)`
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
if (res.statusCode >= 400) {
|
|
3288
|
+
return { passed: true, details: `HTTP ${res.statusCode} (suspicious Origin rejected)` };
|
|
3289
|
+
}
|
|
3290
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
3291
|
+
} catch {
|
|
3292
|
+
return { passed: true, details: "Connection rejected (acceptable)" };
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
);
|
|
3296
|
+
const REJECTION_PATTERNS = [
|
|
3297
|
+
/access denied/i,
|
|
3298
|
+
/permission denied/i,
|
|
3299
|
+
/not allowed/i,
|
|
3300
|
+
/not permitted/i,
|
|
3301
|
+
/outside (allowed|permitted)/i,
|
|
3302
|
+
/forbidden/i,
|
|
3303
|
+
/unauthorized/i,
|
|
3304
|
+
/invalid (path|input|argument|parameter|request)/i,
|
|
3305
|
+
/(payload|request) (rejected|blocked|refused)/i,
|
|
3306
|
+
/enoent|eacces|eperm/i,
|
|
3307
|
+
/sandbox(ed)?/i,
|
|
3308
|
+
/(no such file|file not found)/i,
|
|
3309
|
+
/\binvalid\b.*\b(input|json|argument|parameter|character)/i
|
|
3310
|
+
];
|
|
3311
|
+
async function runInjectionTest(toolName, paramName, payloads, detectPattern, label) {
|
|
3312
|
+
const issues = [];
|
|
3313
|
+
let defended = 0;
|
|
3314
|
+
for (const payload of payloads) {
|
|
3315
|
+
try {
|
|
3316
|
+
const res = await rpc("tools/call", { name: toolName, arguments: { [paramName]: payload } });
|
|
3317
|
+
const result = res.body?.result;
|
|
3318
|
+
const content = result?.content;
|
|
3319
|
+
const isErrorFlag = result?.isError === true;
|
|
3320
|
+
if (Array.isArray(content)) {
|
|
3321
|
+
const text = content.map((c) => c.text || "").join(" ");
|
|
3322
|
+
if (detectPattern.test(text)) {
|
|
3323
|
+
if (looksRejected2(text, isErrorFlag)) {
|
|
3324
|
+
defended++;
|
|
3325
|
+
} else {
|
|
3326
|
+
issues.push(`Payload "${payload}" ${label} (output: ${text.substring(0, 100)})`);
|
|
3327
|
+
}
|
|
3328
|
+
} else {
|
|
3329
|
+
defended++;
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
} catch {
|
|
3333
|
+
defended++;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
3337
|
+
return {
|
|
3338
|
+
passed: true,
|
|
3339
|
+
details: defended === payloads.length ? `Tested ${payloads.length} payloads against ${toolName}.${paramName} \u2014 server defended (rejected or sanitized)` : `Tested ${payloads.length} payloads against ${toolName}.${paramName} \u2014 no ${label.split(" ")[0]} detected`
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
if (toolNames.length > 0) {
|
|
3343
|
+
const allTools = cachedToolsList ?? [];
|
|
3344
|
+
const toolsWithStringParams = allTools.filter((t) => {
|
|
3345
|
+
const props = t.inputSchema?.properties;
|
|
3346
|
+
if (!props) return false;
|
|
3347
|
+
return Object.values(props).some((p) => p.type === "string");
|
|
3348
|
+
});
|
|
3349
|
+
const injectionTarget = toolsWithStringParams[0] || allTools[0];
|
|
3350
|
+
const targetStringParam = injectionTarget?.inputSchema?.properties ? Object.entries(injectionTarget.inputSchema.properties).find(
|
|
3351
|
+
([_, v]) => v.type === "string"
|
|
3352
|
+
)?.[0] ?? null : null;
|
|
3353
|
+
await test(
|
|
3354
|
+
"security-command-injection",
|
|
3355
|
+
"Resists command injection in tool params",
|
|
3356
|
+
"security",
|
|
3357
|
+
false,
|
|
3358
|
+
"server/tools#calling-tools",
|
|
3359
|
+
async () => {
|
|
3360
|
+
if (!injectionTarget || !targetStringParam)
|
|
3361
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
3362
|
+
return runInjectionTest(
|
|
3363
|
+
injectionTarget.name,
|
|
3364
|
+
targetStringParam,
|
|
3365
|
+
INJECTION_PAYLOADS.command,
|
|
3366
|
+
/root:.*:\d+:\d+:.*:\/|uid=\d+\(\w+\)|drwxr|pwned/i,
|
|
3367
|
+
"appears to have executed"
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
);
|
|
3371
|
+
await test(
|
|
3372
|
+
"security-sql-injection",
|
|
3373
|
+
"Resists SQL injection in tool params",
|
|
3374
|
+
"security",
|
|
3375
|
+
false,
|
|
3376
|
+
"server/tools#calling-tools",
|
|
3377
|
+
async () => {
|
|
3378
|
+
if (!injectionTarget || !targetStringParam)
|
|
3379
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
3380
|
+
return runInjectionTest(
|
|
3381
|
+
injectionTarget.name,
|
|
3382
|
+
targetStringParam,
|
|
3383
|
+
INJECTION_PAYLOADS.sql,
|
|
3384
|
+
/syntax error|sql|mysql|postgres|sqlite|information_schema|table_name/i,
|
|
3385
|
+
"triggered database error"
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
);
|
|
3389
|
+
await test(
|
|
3390
|
+
"security-path-traversal",
|
|
3391
|
+
"Resists path traversal in tool params",
|
|
3392
|
+
"security",
|
|
3393
|
+
false,
|
|
3394
|
+
"server/tools#calling-tools",
|
|
3395
|
+
async () => {
|
|
3396
|
+
if (!injectionTarget || !targetStringParam)
|
|
3397
|
+
return { passed: true, details: "No tools with string parameters to test" };
|
|
3398
|
+
return runInjectionTest(
|
|
3399
|
+
injectionTarget.name,
|
|
3400
|
+
targetStringParam,
|
|
3401
|
+
INJECTION_PAYLOADS.pathTraversal,
|
|
3402
|
+
/root:.*:0:0|\[boot loader\]|\[extensions\]/i,
|
|
3403
|
+
"returned sensitive file content"
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
);
|
|
3407
|
+
await test(
|
|
3408
|
+
"security-ssrf-internal",
|
|
3409
|
+
"Resists SSRF to internal networks",
|
|
3410
|
+
"security",
|
|
3411
|
+
false,
|
|
3412
|
+
"server/tools#calling-tools",
|
|
3413
|
+
async () => {
|
|
3414
|
+
const urlParamTool = allTools.find((t) => {
|
|
3415
|
+
const props = t.inputSchema?.properties;
|
|
3416
|
+
if (!props) return false;
|
|
3417
|
+
return Object.entries(props).some(
|
|
3418
|
+
([k, v]) => v.type === "string" && /url|uri|endpoint|link|href/i.test(k)
|
|
3419
|
+
);
|
|
3420
|
+
});
|
|
3421
|
+
if (!urlParamTool) return { passed: true, details: "No tools with URL parameters found (skipped)" };
|
|
3422
|
+
const urlParam = Object.entries(urlParamTool.inputSchema.properties).find(
|
|
3423
|
+
([k, v]) => v.type === "string" && /url|uri|endpoint|link|href/i.test(k)
|
|
3424
|
+
)?.[0];
|
|
3425
|
+
if (!urlParam) return { passed: true, details: "No URL parameter found" };
|
|
3426
|
+
return runInjectionTest(
|
|
3427
|
+
urlParamTool.name,
|
|
3428
|
+
urlParam,
|
|
3429
|
+
INJECTION_PAYLOADS.ssrf,
|
|
3430
|
+
/ami-|instance-id|hostname|iam|security-credentials/i,
|
|
3431
|
+
"returned internal data"
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
);
|
|
3435
|
+
} else {
|
|
3436
|
+
for (const testId of [
|
|
3437
|
+
"security-command-injection",
|
|
3438
|
+
"security-sql-injection",
|
|
3439
|
+
"security-path-traversal",
|
|
3440
|
+
"security-ssrf-internal"
|
|
3441
|
+
]) {
|
|
3442
|
+
await test(
|
|
3443
|
+
testId,
|
|
3444
|
+
TEST_DEFINITIONS_MAP.get(testId)?.name || testId,
|
|
3445
|
+
"security",
|
|
3446
|
+
false,
|
|
3447
|
+
"server/tools#calling-tools",
|
|
3448
|
+
async () => ({ passed: true, details: "No tools available to test (skipped)" })
|
|
3449
|
+
);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
await test(
|
|
3453
|
+
"security-oversized-input",
|
|
3454
|
+
"Handles oversized inputs gracefully",
|
|
3455
|
+
"security",
|
|
3456
|
+
false,
|
|
3457
|
+
"server/tools#calling-tools",
|
|
3458
|
+
async () => {
|
|
3459
|
+
const largeValue = "A".repeat(1048576);
|
|
3460
|
+
try {
|
|
3461
|
+
const res = await request2(backendUrl, {
|
|
3462
|
+
method: "POST",
|
|
3463
|
+
headers: {
|
|
3464
|
+
"Content-Type": "application/json",
|
|
3465
|
+
Accept: "application/json, text/event-stream",
|
|
3466
|
+
...buildHeaders2()
|
|
3467
|
+
},
|
|
3468
|
+
body: JSON.stringify({
|
|
3469
|
+
jsonrpc: "2.0",
|
|
3470
|
+
id: nextId(),
|
|
3471
|
+
method: "tools/call",
|
|
3472
|
+
params: { name: toolNames[0] || "test", arguments: { data: largeValue } }
|
|
3473
|
+
}),
|
|
3474
|
+
signal: AbortSignal.timeout(timeout)
|
|
3475
|
+
});
|
|
3476
|
+
await res.body.text();
|
|
3477
|
+
if (res.statusCode === 413) {
|
|
3478
|
+
return { passed: true, details: "HTTP 413 Payload Too Large (good)" };
|
|
3479
|
+
}
|
|
3480
|
+
if (res.statusCode >= 400) {
|
|
3481
|
+
return { passed: true, details: `HTTP ${res.statusCode} (oversized input rejected)` };
|
|
3482
|
+
}
|
|
3483
|
+
return { passed: true, details: `HTTP ${res.statusCode} \u2014 server handled 1MB payload without crashing` };
|
|
3484
|
+
} catch (err) {
|
|
3485
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3486
|
+
if (msg.includes("timeout") || msg.includes("abort")) {
|
|
3487
|
+
return { passed: false, details: "Request timed out \u2014 server may be struggling with oversized input" };
|
|
3488
|
+
}
|
|
3489
|
+
return { passed: true, details: "Connection rejected (acceptable for oversized input)" };
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
);
|
|
3493
|
+
await test(
|
|
3494
|
+
"security-extra-params",
|
|
3495
|
+
"Rejects or ignores extra tool params",
|
|
3496
|
+
"security",
|
|
3497
|
+
false,
|
|
3498
|
+
"server/tools#calling-tools",
|
|
3499
|
+
async () => {
|
|
3500
|
+
if (toolNames.length === 0) {
|
|
3501
|
+
return { passed: true, details: "No tools available to test (skipped)" };
|
|
3502
|
+
}
|
|
3503
|
+
try {
|
|
3504
|
+
const res = await rpc("tools/call", {
|
|
3505
|
+
name: toolNames[0],
|
|
3506
|
+
arguments: { __injected_param__: "malicious_value", __proto__: { admin: true } }
|
|
3507
|
+
});
|
|
3508
|
+
const error = res.body?.error;
|
|
3509
|
+
if (error) {
|
|
3510
|
+
return { passed: true, details: `Extra params rejected with error: ${error.code} \u2014 ${error.message}` };
|
|
3511
|
+
}
|
|
3512
|
+
return { passed: true, details: "Server processed request (extra params likely ignored)" };
|
|
3513
|
+
} catch {
|
|
3514
|
+
return { passed: true, details: "Request rejected (acceptable)" };
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
);
|
|
3518
|
+
await test(
|
|
3519
|
+
"security-tool-schema-defined",
|
|
3520
|
+
"All tools define inputSchema",
|
|
3521
|
+
"security",
|
|
3522
|
+
false,
|
|
3523
|
+
"server/tools#data-types",
|
|
3524
|
+
async () => {
|
|
3525
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
3526
|
+
const tools = cachedToolsList ?? [];
|
|
3527
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
3528
|
+
const missing = tools.filter((t) => !t.inputSchema || t.inputSchema.type !== "object");
|
|
3529
|
+
if (missing.length > 0) {
|
|
3530
|
+
return {
|
|
3531
|
+
passed: false,
|
|
3532
|
+
details: `${missing.length} tool(s) missing inputSchema: ${missing.map((t) => t.name).join(", ")}`
|
|
3533
|
+
};
|
|
3534
|
+
}
|
|
3535
|
+
return { passed: true, details: `All ${tools.length} tool(s) have inputSchema defined` };
|
|
3536
|
+
}
|
|
3537
|
+
);
|
|
3538
|
+
await test(
|
|
3539
|
+
"security-tool-rug-pull",
|
|
3540
|
+
"Tool definitions are stable across calls",
|
|
3541
|
+
"security",
|
|
3542
|
+
false,
|
|
3543
|
+
"server/tools#listing-tools",
|
|
3544
|
+
async () => {
|
|
3545
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
3546
|
+
try {
|
|
3547
|
+
const res = await rpc("tools/list");
|
|
3548
|
+
const tools2 = res.body?.result?.tools;
|
|
3549
|
+
if (!Array.isArray(tools2)) return { passed: false, details: "Second tools/list call failed" };
|
|
3550
|
+
const tools1 = cachedToolsList ?? [];
|
|
3551
|
+
if (tools1.length !== tools2.length) {
|
|
3552
|
+
return {
|
|
3553
|
+
passed: false,
|
|
3554
|
+
details: `Tool count changed: ${tools1.length} \u2192 ${tools2.length} (possible rug-pull)`
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
const names1 = tools1.map((t) => t.name).sort().join(",");
|
|
3558
|
+
const names2 = tools2.map((t) => t.name).sort().join(",");
|
|
3559
|
+
if (names1 !== names2) {
|
|
3560
|
+
return { passed: false, details: "Tool names changed between calls (possible rug-pull)" };
|
|
3561
|
+
}
|
|
3562
|
+
for (const t1 of tools1) {
|
|
3563
|
+
const t2 = tools2.find((t) => t.name === t1.name);
|
|
3564
|
+
if (t2 && t1.description !== t2.description) {
|
|
3565
|
+
return {
|
|
3566
|
+
passed: false,
|
|
3567
|
+
details: `Tool "${t1.name}" description changed between calls (possible rug-pull)`
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
return { passed: true, details: `${tools1.length} tool(s) consistent across 2 calls` };
|
|
3572
|
+
} catch {
|
|
3573
|
+
return { passed: false, details: "Second tools/list call threw an error" };
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
);
|
|
3577
|
+
await test(
|
|
3578
|
+
"security-tool-description-poisoning",
|
|
3579
|
+
"Tool descriptions free of injection patterns",
|
|
3580
|
+
"security",
|
|
3581
|
+
false,
|
|
3582
|
+
"server/tools#data-types",
|
|
3583
|
+
async () => {
|
|
3584
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
3585
|
+
const tools = cachedToolsList ?? [];
|
|
3586
|
+
if (tools.length === 0) return { passed: true, details: "No tools to validate" };
|
|
3587
|
+
const suspiciousPatterns = [
|
|
3588
|
+
{ pattern: /ignore\s+(all\s+)?previous/i, label: "ignore previous instructions" },
|
|
3589
|
+
{ pattern: /override\s+(system|instructions|rules)/i, label: "override instructions" },
|
|
3590
|
+
{ pattern: /system\s+prompt/i, label: "system prompt reference" },
|
|
3591
|
+
{ pattern: /you\s+must\s+(always|never)/i, label: "behavioral override" },
|
|
3592
|
+
{ pattern: /do\s+not\s+(tell|inform|mention)/i, label: "concealment instruction" },
|
|
3593
|
+
{ pattern: /\b[A-Za-z0-9+/]{40,}={0,2}\b/, label: "possible Base64-encoded payload" },
|
|
3594
|
+
// Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
|
|
3595
|
+
{ pattern: /\u200B|\u200C|\u200D|\uFEFF/, label: "hidden Unicode characters" }
|
|
3596
|
+
];
|
|
3597
|
+
const issues = [];
|
|
3598
|
+
for (const tool of tools) {
|
|
3599
|
+
const textsToCheck = [
|
|
3600
|
+
tool.description || "",
|
|
3601
|
+
...tool.inputSchema?.properties ? Object.values(tool.inputSchema.properties).map((p) => p.description || "") : []
|
|
3602
|
+
];
|
|
3603
|
+
const combined = textsToCheck.join(" ");
|
|
3604
|
+
for (const { pattern, label } of suspiciousPatterns) {
|
|
3605
|
+
if (pattern.test(combined)) {
|
|
3606
|
+
issues.push(`Tool "${tool.name}": ${label}`);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
if (issues.length > 0) return { passed: false, details: issues.join("; ") };
|
|
3611
|
+
return { passed: true, details: `${tools.length} tool(s) scanned \u2014 no injection patterns found` };
|
|
3612
|
+
}
|
|
3613
|
+
);
|
|
3614
|
+
await test(
|
|
3615
|
+
"security-tool-cross-reference",
|
|
3616
|
+
"Tools do not reference other tools by name",
|
|
3617
|
+
"security",
|
|
3618
|
+
false,
|
|
3619
|
+
"server/tools#data-types",
|
|
3620
|
+
async () => {
|
|
3621
|
+
if (!toolsListOk) return { passed: true, details: "Skipped: tools/list not available" };
|
|
3622
|
+
const tools = cachedToolsList ?? [];
|
|
3623
|
+
if (tools.length < 2)
|
|
3624
|
+
return { passed: true, details: "Fewer than 2 tools \u2014 cross-reference check not applicable" };
|
|
3625
|
+
const names = tools.map((t) => t.name).filter(Boolean);
|
|
3626
|
+
const issues = [];
|
|
3627
|
+
for (const tool of tools) {
|
|
3628
|
+
const desc = (tool.description || "").toLowerCase();
|
|
3629
|
+
for (const otherName of names) {
|
|
3630
|
+
if (otherName === tool.name) continue;
|
|
3631
|
+
if (desc.includes(otherName.toLowerCase())) {
|
|
3632
|
+
issues.push(`Tool "${tool.name}" description references "${otherName}"`);
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
if (issues.length > 0) {
|
|
3637
|
+
warnings.push(`Cross-tool references found: ${issues.join("; ")}`);
|
|
3638
|
+
return { passed: false, details: issues.join("; ") };
|
|
3639
|
+
}
|
|
3640
|
+
return { passed: true, details: `${tools.length} tool(s) checked \u2014 no cross-references found` };
|
|
3641
|
+
}
|
|
3642
|
+
);
|
|
3643
|
+
await test(
|
|
3644
|
+
"security-error-no-stacktrace",
|
|
3645
|
+
"Error responses do not leak stack traces",
|
|
3646
|
+
"security",
|
|
3647
|
+
false,
|
|
3648
|
+
"basic",
|
|
3649
|
+
async () => {
|
|
3650
|
+
const errorResponses = [];
|
|
3651
|
+
const errorPayloads = [
|
|
3652
|
+
"{this is not valid json!!!",
|
|
3653
|
+
JSON.stringify({ jsonrpc: "2.0", id: nextId(), method: "nonexistent/___crash___test___" }),
|
|
3654
|
+
JSON.stringify({
|
|
3655
|
+
jsonrpc: "2.0",
|
|
3656
|
+
id: nextId(),
|
|
3657
|
+
method: "tools/call",
|
|
3658
|
+
params: { name: "___nonexistent___tool___" }
|
|
3659
|
+
})
|
|
3660
|
+
];
|
|
3661
|
+
for (const payload of errorPayloads) {
|
|
3662
|
+
try {
|
|
3663
|
+
const res = await request2(backendUrl, {
|
|
3664
|
+
method: "POST",
|
|
3665
|
+
headers: {
|
|
3666
|
+
"Content-Type": "application/json",
|
|
3667
|
+
Accept: "application/json, text/event-stream",
|
|
3668
|
+
...buildHeaders2()
|
|
3669
|
+
},
|
|
3670
|
+
body: payload,
|
|
3671
|
+
signal: AbortSignal.timeout(timeout)
|
|
3672
|
+
});
|
|
3673
|
+
const text = await res.body.text();
|
|
3674
|
+
errorResponses.push(text);
|
|
3675
|
+
} catch {
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
const issues = [];
|
|
3679
|
+
for (const text of errorResponses) {
|
|
3680
|
+
for (const pattern of STACK_TRACE_PATTERNS) {
|
|
3681
|
+
if (pattern.test(text)) {
|
|
3682
|
+
issues.push(`Response contains: ${pattern.source} (matched in: ${text.substring(0, 80)}...)`);
|
|
3683
|
+
break;
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
if (issues.length > 0) return { passed: false, details: issues.slice(0, 3).join("; ") };
|
|
3688
|
+
return {
|
|
3689
|
+
passed: true,
|
|
3690
|
+
details: `${errorResponses.length} error responses checked \u2014 no stack traces or sensitive data found`
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
);
|
|
3694
|
+
await test(
|
|
3695
|
+
"security-error-no-internal-ip",
|
|
3696
|
+
"Error responses do not leak internal IPs",
|
|
3697
|
+
"security",
|
|
3698
|
+
false,
|
|
3699
|
+
"basic",
|
|
3700
|
+
async () => {
|
|
3701
|
+
try {
|
|
3702
|
+
const res = await request2(backendUrl, {
|
|
3703
|
+
method: "POST",
|
|
3704
|
+
headers: {
|
|
3705
|
+
"Content-Type": "application/json",
|
|
3706
|
+
Accept: "application/json, text/event-stream",
|
|
3707
|
+
...buildHeaders2()
|
|
3708
|
+
},
|
|
3709
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: nextId(), method: "___trigger_error___" }),
|
|
3710
|
+
signal: AbortSignal.timeout(timeout)
|
|
3711
|
+
});
|
|
3712
|
+
const text = await res.body.text();
|
|
3713
|
+
for (const pattern of INTERNAL_IP_PATTERNS) {
|
|
3714
|
+
const match = text.match(pattern);
|
|
3715
|
+
if (match) {
|
|
3716
|
+
return { passed: false, details: `Error response contains internal IP: ${match[0]}` };
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
return { passed: true, details: "No internal IP addresses found in error responses" };
|
|
3720
|
+
} catch {
|
|
3721
|
+
return { passed: true, details: "No response to check (connection error)" };
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
);
|
|
3725
|
+
await test(
|
|
3726
|
+
"security-rate-limiting",
|
|
3727
|
+
"Rate limiting is enforced",
|
|
3728
|
+
"security",
|
|
3729
|
+
false,
|
|
3730
|
+
"basic/transports#streamable-http",
|
|
3731
|
+
async () => {
|
|
3732
|
+
const burstSize = 50;
|
|
3733
|
+
let got429 = false;
|
|
3734
|
+
const promises = Array.from(
|
|
3735
|
+
{ length: burstSize },
|
|
3736
|
+
() => mcpRequest(backendUrl, "ping", void 0, nextId, buildHeaders2(), timeout).then((res) => {
|
|
3737
|
+
if (res.statusCode === 429) got429 = true;
|
|
3738
|
+
return res.statusCode;
|
|
3739
|
+
}).catch(() => 0)
|
|
3740
|
+
);
|
|
3741
|
+
const statusCodes = await Promise.all(promises);
|
|
3742
|
+
if (got429) {
|
|
3743
|
+
return { passed: true, details: `Rate limiting detected (429 returned after ${burstSize} rapid requests)` };
|
|
3744
|
+
}
|
|
3745
|
+
const errorCount = statusCodes.filter((c) => c >= 500).length;
|
|
3746
|
+
if (errorCount > burstSize / 2) {
|
|
3747
|
+
return {
|
|
3748
|
+
passed: false,
|
|
3749
|
+
details: `Server returned ${errorCount}/${burstSize} 5xx errors under load \u2014 should return 429 instead of crashing`
|
|
3750
|
+
};
|
|
3751
|
+
}
|
|
3752
|
+
return {
|
|
3753
|
+
passed: false,
|
|
3754
|
+
details: `No rate limiting detected (${burstSize} rapid requests all returned ${[...new Set(statusCodes)].join(",")})`
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
);
|
|
3758
|
+
await test(
|
|
3759
|
+
"transport-delete",
|
|
3760
|
+
"DELETE accepted or returns 405",
|
|
3761
|
+
"transport",
|
|
3762
|
+
false,
|
|
3763
|
+
"basic/transports#streamable-http",
|
|
3764
|
+
async () => {
|
|
3765
|
+
const deleteHeaders = { ...buildHeaders2() };
|
|
3766
|
+
const res = await request2(backendUrl, {
|
|
3767
|
+
method: "DELETE",
|
|
3768
|
+
headers: deleteHeaders,
|
|
3769
|
+
signal: AbortSignal.timeout(timeout)
|
|
3770
|
+
});
|
|
3771
|
+
await res.body.text();
|
|
3772
|
+
if (res.statusCode === 405) {
|
|
3773
|
+
return { passed: true, details: "HTTP 405 Method Not Allowed (acceptable)" };
|
|
3774
|
+
}
|
|
3775
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
3776
|
+
if (sessionId) {
|
|
3777
|
+
try {
|
|
3778
|
+
const verifyRes = await mcpRequest(
|
|
3779
|
+
backendUrl,
|
|
3780
|
+
"ping",
|
|
3781
|
+
void 0,
|
|
3782
|
+
createIdCounter(99920),
|
|
3783
|
+
deleteHeaders,
|
|
3784
|
+
timeout
|
|
3785
|
+
);
|
|
3786
|
+
if (verifyRes.statusCode === 400 || verifyRes.statusCode === 404 || verifyRes.statusCode === 409) {
|
|
3787
|
+
return {
|
|
3788
|
+
passed: true,
|
|
3789
|
+
details: `HTTP ${res.statusCode} (session terminated, post-delete request correctly rejected with ${verifyRes.statusCode})`
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
} catch {
|
|
3793
|
+
return {
|
|
3794
|
+
passed: true,
|
|
3795
|
+
details: `HTTP ${res.statusCode} (session terminated, post-delete request rejected)`
|
|
3796
|
+
};
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
return { passed: true, details: `HTTP ${res.statusCode} (session termination supported)` };
|
|
3800
|
+
}
|
|
3801
|
+
if (res.statusCode === 400 || res.statusCode === 404) {
|
|
3802
|
+
return { passed: true, details: `HTTP ${res.statusCode} (no active session, acceptable)` };
|
|
3803
|
+
}
|
|
3804
|
+
return { passed: false, details: `HTTP ${res.statusCode}` };
|
|
3805
|
+
}
|
|
3806
|
+
);
|
|
3807
|
+
await test(
|
|
3808
|
+
"stdio-framing",
|
|
3809
|
+
"Newline-delimited JSON framing",
|
|
3810
|
+
"transport",
|
|
3811
|
+
true,
|
|
3812
|
+
"basic/transports#stdio",
|
|
3813
|
+
async () => {
|
|
3814
|
+
const results = await Promise.all(
|
|
3815
|
+
Array.from({ length: 5 }, () => rpc("ping").catch((e) => ({ _err: e.message })))
|
|
3816
|
+
);
|
|
3817
|
+
const failed = results.filter((r) => "_err" in r);
|
|
3818
|
+
if (failed.length) {
|
|
3819
|
+
return { passed: false, details: `${failed.length}/5 rapid pings failed \u2014 framing likely broken` };
|
|
3820
|
+
}
|
|
3821
|
+
return { passed: true, details: "5/5 rapid pings returned cleanly" };
|
|
3822
|
+
}
|
|
3823
|
+
);
|
|
3824
|
+
await test("stdio-unicode", "UTF-8 unicode roundtrip", "transport", false, "basic/transports#stdio", async () => {
|
|
3825
|
+
const probe = "h\xE9llo \u4E16\u754C \u{1F680}";
|
|
3826
|
+
if (hasTools && toolNames.length > 0) {
|
|
3827
|
+
try {
|
|
3828
|
+
const res2 = await rpc("tools/call", {
|
|
3829
|
+
name: toolNames[0],
|
|
3830
|
+
arguments: { message: probe, text: probe, input: probe, query: probe }
|
|
3831
|
+
});
|
|
3832
|
+
const serialized = JSON.stringify(res2.body);
|
|
3833
|
+
if (serialized.includes(probe)) {
|
|
3834
|
+
return { passed: true, details: "Unicode string round-tripped through tool call" };
|
|
3835
|
+
}
|
|
3836
|
+
return { passed: true, details: "Tool echoed something, but not the exact probe \u2014 likely still UTF-8-safe" };
|
|
3837
|
+
} catch (err) {
|
|
3838
|
+
return { passed: false, details: `tools/call threw \u2014 ${err instanceof Error ? err.message : String(err)}` };
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
const res = await rpc("tools/list");
|
|
3842
|
+
if (res.body.error) {
|
|
3843
|
+
return { passed: false, details: "tools/list returned error" };
|
|
3844
|
+
}
|
|
3845
|
+
return { passed: true, details: "tools/list returned successfully (no tools to probe with unicode)" };
|
|
3846
|
+
});
|
|
3847
|
+
await test(
|
|
3848
|
+
"stdio-unknown-method-recovers",
|
|
3849
|
+
"Recovers after unknown method",
|
|
3850
|
+
"transport",
|
|
3851
|
+
false,
|
|
3852
|
+
"basic/transports#stdio",
|
|
3853
|
+
async () => {
|
|
3854
|
+
const errRes = await rpc("this/method/does/not/exist-xyzzy");
|
|
3855
|
+
const errBody = errRes.body;
|
|
3856
|
+
if (!errBody.error) {
|
|
3857
|
+
return { passed: false, details: "Unknown method did not produce a JSON-RPC error" };
|
|
3858
|
+
}
|
|
3859
|
+
const okRes = await rpc("ping");
|
|
3860
|
+
const okBody = okRes.body;
|
|
3861
|
+
if (okBody.error) {
|
|
3862
|
+
return {
|
|
3863
|
+
passed: false,
|
|
3864
|
+
details: "Server responded with error to ping after unknown method \u2014 may have desynced"
|
|
3865
|
+
};
|
|
3866
|
+
}
|
|
3867
|
+
return { passed: true, details: "Unknown method returned JSON-RPC error; subsequent ping succeeded" };
|
|
3868
|
+
}
|
|
3869
|
+
);
|
|
3870
|
+
const MAX_WARNINGS = 100;
|
|
3871
|
+
if (warnings.length > MAX_WARNINGS) {
|
|
3872
|
+
const truncated = warnings.length - MAX_WARNINGS;
|
|
3873
|
+
warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
|
|
3874
|
+
}
|
|
3875
|
+
const { score, grade, overall, summary, categories } = computeScore(tests);
|
|
3876
|
+
const badge = generateBadge(displayUrl);
|
|
3877
|
+
return {
|
|
3878
|
+
specVersion: SPEC_VERSION,
|
|
3879
|
+
toolVersion: TOOL_VERSION,
|
|
3880
|
+
url: displayUrl,
|
|
3881
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3882
|
+
score,
|
|
3883
|
+
grade,
|
|
3884
|
+
overall,
|
|
3885
|
+
summary,
|
|
3886
|
+
categories,
|
|
3887
|
+
tests,
|
|
3888
|
+
warnings,
|
|
3889
|
+
serverInfo,
|
|
3890
|
+
toolCount,
|
|
3891
|
+
toolNames,
|
|
3892
|
+
resourceCount,
|
|
3893
|
+
resourceNames,
|
|
3894
|
+
promptCount,
|
|
3895
|
+
promptNames,
|
|
3896
|
+
badge
|
|
3897
|
+
};
|
|
3898
|
+
} finally {
|
|
3899
|
+
await transport.close().catch(() => {
|
|
3900
|
+
});
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
export {
|
|
3905
|
+
generateBadge,
|
|
3906
|
+
computeGrade,
|
|
3907
|
+
computeScore,
|
|
3908
|
+
parseSSEResponse,
|
|
3909
|
+
TEST_DEFINITIONS,
|
|
3910
|
+
SPEC_VERSION,
|
|
3911
|
+
SPEC_BASE,
|
|
3912
|
+
previewTests,
|
|
3913
|
+
runComplianceSuite
|
|
3914
|
+
};
|