@yawlabs/mcp-compliance 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // src/runner.ts
2
2
  import { createRequire } from "module";
3
- import { request } from "undici";
3
+ import { request as request2 } from "undici";
4
4
 
5
5
  // src/badge.ts
6
6
  import { createHash } from "crypto";
@@ -9,8 +9,8 @@ function urlHash(url) {
9
9
  }
10
10
  function generateBadge(url) {
11
11
  const hash = urlHash(url);
12
- const imageUrl = `https://mcp.hosting/api/compliance/${hash}/badge`;
13
- const reportUrl = `https://mcp.hosting/compliance/${hash}`;
12
+ const imageUrl = `https://mcp.hosting/api/compliance/ext/${hash}/badge`;
13
+ const reportUrl = `https://mcp.hosting/compliance/ext/${hash}`;
14
14
  return {
15
15
  imageUrl,
16
16
  reportUrl,
@@ -54,6 +54,307 @@ function computeScore(tests) {
54
54
  };
55
55
  }
56
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 isWindows = process.platform === "win32";
200
+ const child = spawn(command, args, {
201
+ env: env ? { ...process.env, ...env } : process.env,
202
+ cwd,
203
+ stdio: ["pipe", "pipe", "pipe"],
204
+ // Windows .cmd/.bat shims (npx, npm) need shell:true to launch.
205
+ shell: isWindows
206
+ });
207
+ let protocolVersion = null;
208
+ let exited = false;
209
+ let exitCode = null;
210
+ let spawnError = null;
211
+ const pending = /* @__PURE__ */ new Map();
212
+ let stdoutBuffer = "";
213
+ let stderrBuffer = "";
214
+ child.on("error", (err) => {
215
+ spawnError = err;
216
+ rejectAllPending(err);
217
+ });
218
+ child.on("exit", (code, signal) => {
219
+ exited = true;
220
+ exitCode = code;
221
+ if (pending.size > 0) {
222
+ const reason = signal ? `child exited (signal ${signal})` : `child exited with code ${code}`;
223
+ rejectAllPending(new Error(reason));
224
+ }
225
+ });
226
+ child.stdout?.setEncoding("utf8");
227
+ child.stdout?.on("data", (chunk) => {
228
+ stdoutBuffer += chunk;
229
+ let idx;
230
+ while ((idx = stdoutBuffer.indexOf("\n")) !== -1) {
231
+ const line = stdoutBuffer.slice(0, idx);
232
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
233
+ handleLine(line);
234
+ }
235
+ });
236
+ child.stderr?.setEncoding("utf8");
237
+ child.stderr?.on("data", (chunk) => {
238
+ if (verbose) process.stderr.write(chunk);
239
+ stderrBuffer += chunk;
240
+ if (stderrBuffer.length > stderrBufferSize) {
241
+ stderrBuffer = stderrBuffer.slice(stderrBuffer.length - stderrBufferSize);
242
+ }
243
+ });
244
+ function handleLine(line) {
245
+ const trimmed = line.trim();
246
+ if (!trimmed) return;
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(trimmed);
250
+ } catch {
251
+ return;
252
+ }
253
+ if (!parsed || typeof parsed !== "object") return;
254
+ const msg = parsed;
255
+ if (typeof msg.id === "number" && pending.has(msg.id)) {
256
+ const p = pending.get(msg.id);
257
+ if (!p) return;
258
+ clearTimeout(p.timer);
259
+ pending.delete(msg.id);
260
+ p.resolve({ body: parsed, requestId: msg.id });
261
+ }
262
+ }
263
+ function rejectAllPending(err) {
264
+ for (const p of pending.values()) {
265
+ clearTimeout(p.timer);
266
+ p.reject(err);
267
+ }
268
+ pending.clear();
269
+ }
270
+ async function writeLine(line) {
271
+ if (exited) throw new Error("stdio transport: child has exited");
272
+ if (spawnError) throw new Error(`stdio transport: spawn failed \u2014 ${spawnError.message}`);
273
+ const stdin = child.stdin;
274
+ if (!stdin || stdin.destroyed) throw new Error("stdio transport: stdin is closed");
275
+ return new Promise((resolve, reject) => {
276
+ stdin.write(`${line}
277
+ `, "utf8", (err) => err ? reject(err) : resolve());
278
+ });
279
+ }
280
+ const transport = {
281
+ kind: "stdio",
282
+ command,
283
+ args,
284
+ get pid() {
285
+ return child.pid;
286
+ },
287
+ get exited() {
288
+ return exited;
289
+ },
290
+ get exitCode() {
291
+ return exitCode;
292
+ },
293
+ stderrTail() {
294
+ return stderrBuffer;
295
+ },
296
+ async request(method, params, nextId, init) {
297
+ const id = nextId();
298
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
299
+ return new Promise((resolve, reject) => {
300
+ const timer = setTimeout(() => {
301
+ pending.delete(id);
302
+ reject(new Error(`stdio transport: request timed out after ${init.timeout}ms (method=${method})`));
303
+ }, init.timeout);
304
+ pending.set(id, { resolve, reject, id, timer });
305
+ writeLine(body).catch((err) => {
306
+ clearTimeout(timer);
307
+ pending.delete(id);
308
+ reject(err);
309
+ });
310
+ });
311
+ },
312
+ async notify(method, params, _init) {
313
+ const body = JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} });
314
+ await writeLine(body);
315
+ return {};
316
+ },
317
+ async close() {
318
+ if (exited) return;
319
+ try {
320
+ child.stdin?.end();
321
+ } catch {
322
+ }
323
+ const gracePeriodMs = 2e3;
324
+ await new Promise((resolve) => {
325
+ const timer = setTimeout(() => {
326
+ try {
327
+ child.kill("SIGKILL");
328
+ } catch {
329
+ }
330
+ resolve();
331
+ }, gracePeriodMs);
332
+ child.once("exit", () => {
333
+ clearTimeout(timer);
334
+ resolve();
335
+ });
336
+ try {
337
+ child.kill(isWindows ? void 0 : "SIGTERM");
338
+ } catch {
339
+ }
340
+ });
341
+ rejectAllPending(new Error("stdio transport: closed"));
342
+ },
343
+ setSessionId(_id) {
344
+ },
345
+ setProtocolVersion(v) {
346
+ protocolVersion = v;
347
+ },
348
+ getSessionId() {
349
+ return null;
350
+ },
351
+ getProtocolVersion() {
352
+ return protocolVersion;
353
+ }
354
+ };
355
+ return transport;
356
+ }
357
+
57
358
  // src/types.ts
58
359
  var TEST_DEFINITIONS = [
59
360
  // ── Transport (13 tests) ─────────────────────────────────────────
@@ -174,6 +475,37 @@ var TEST_DEFINITIONS = [
174
475
  description: "Sends a request with Accept: text/event-stream and checks that SSE responses include the event: message field. Per spec, servers MUST set event: message for JSON-RPC messages in SSE streams.",
175
476
  recommendation: 'Include "event: message" before each "data:" line in your SSE responses. This is required by the MCP spec for JSON-RPC messages sent over SSE.'
176
477
  },
478
+ // ── Stdio transport (stdio-only) ─────────────────────────────────
479
+ {
480
+ id: "stdio-framing",
481
+ name: "Newline-delimited JSON framing",
482
+ category: "transport",
483
+ required: true,
484
+ specRef: "basic/transports#stdio",
485
+ 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.",
486
+ 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.",
487
+ transports: ["stdio"]
488
+ },
489
+ {
490
+ id: "stdio-unicode",
491
+ name: "UTF-8 unicode roundtrip",
492
+ category: "transport",
493
+ required: false,
494
+ specRef: "basic/transports#stdio",
495
+ 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.",
496
+ 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.",
497
+ transports: ["stdio"]
498
+ },
499
+ {
500
+ id: "stdio-unknown-method-recovers",
501
+ name: "Recovers after unknown method",
502
+ category: "transport",
503
+ required: false,
504
+ specRef: "basic/transports#stdio",
505
+ 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.",
506
+ 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.",
507
+ transports: ["stdio"]
508
+ },
177
509
  // ── Lifecycle (17 tests) ─────────────────────────────────────────
178
510
  {
179
511
  id: "lifecycle-init",
@@ -859,127 +1191,97 @@ function createIdCounter(start = 0) {
859
1191
  let id = start;
860
1192
  return () => ++id;
861
1193
  }
862
- function parseSSEResponse(text) {
863
- const lines = text.split("\n");
864
- let firstJsonRpcResponse = null;
865
- let currentData = [];
866
- function flushEvent() {
867
- if (currentData.length === 0) return;
868
- const data = currentData.join("\n");
869
- currentData = [];
870
- if (!data.trim()) return;
871
- try {
872
- const parsed = JSON.parse(data);
873
- if (!firstJsonRpcResponse && parsed.jsonrpc === "2.0" && parsed.id !== void 0) {
874
- firstJsonRpcResponse = parsed;
875
- }
876
- } catch {
877
- }
878
- }
879
- for (const line of lines) {
880
- if (line.startsWith("data:")) {
881
- const content = line.slice(5);
882
- currentData.push(content.startsWith(" ") ? content.slice(1) : content);
883
- } else if (line.trim() === "") {
884
- flushEvent();
885
- }
886
- }
887
- flushEvent();
888
- return firstJsonRpcResponse;
1194
+ var STDIO_INCOMPATIBLE_IDS = /* @__PURE__ */ new Set([
1195
+ // Lifecycle tests that use raw undici for HTTP-specific checks
1196
+ "lifecycle-string-id",
1197
+ // Error tests that send hand-crafted malformed bytes via raw HTTP
1198
+ // (JSON-RPC layer would reject them before they hit the wire). Could
1199
+ // be reimplemented for stdio later by writing raw bytes to stdin.
1200
+ "error-invalid-jsonrpc",
1201
+ "error-invalid-json",
1202
+ "error-parse-code",
1203
+ "error-invalid-request-code",
1204
+ // Security tests that are inherently HTTP-layer
1205
+ "security-tls-required",
1206
+ "security-oauth-metadata",
1207
+ "security-token-in-uri",
1208
+ "security-rate-limit",
1209
+ "security-cors",
1210
+ "security-origin-validation"
1211
+ ]);
1212
+ function supportsTransport(def, kind) {
1213
+ if (!def) return true;
1214
+ if (def.transports) return def.transports.includes(kind);
1215
+ if (kind === "http") return true;
1216
+ if (def.category === "transport") return false;
1217
+ if (STDIO_INCOMPATIBLE_IDS.has(def.id)) return false;
1218
+ return true;
889
1219
  }
890
- async function mcpRequest(backendUrl, method, params, nextId, extraHeaders, timeout) {
891
- const id = nextId();
892
- const body = JSON.stringify({
893
- jsonrpc: "2.0",
894
- id,
895
- method,
896
- params: params || {}
897
- });
898
- const headers = {
899
- "Content-Type": "application/json",
900
- Accept: "application/json, text/event-stream",
901
- ...extraHeaders
902
- };
903
- const res = await request(backendUrl, {
904
- method: "POST",
905
- headers,
906
- body,
907
- signal: AbortSignal.timeout(timeout)
908
- });
909
- const text = await res.body.text();
910
- const responseHeaders = {};
911
- for (const [k, v] of Object.entries(res.headers)) {
912
- if (typeof v === "string") responseHeaders[k] = v;
913
- }
914
- const contentType = (responseHeaders["content-type"] || "").toLowerCase();
915
- if (contentType.includes("text/event-stream")) {
916
- const parsed = parseSSEResponse(text);
917
- if (parsed) {
918
- return { statusCode: res.statusCode, body: parsed, headers: responseHeaders, requestId: id };
1220
+ function previewTests(opts = {}) {
1221
+ const transport = opts.transport ?? "http";
1222
+ return TEST_DEFINITIONS.filter((def) => {
1223
+ if (!supportsTransport(def, transport)) return false;
1224
+ if (opts.only?.length) {
1225
+ if (!opts.only.includes(def.category) && !opts.only.includes(def.id)) return false;
919
1226
  }
920
- try {
921
- return { statusCode: res.statusCode, body: JSON.parse(text), headers: responseHeaders, requestId: id };
922
- } catch {
923
- return { statusCode: res.statusCode, body: { _raw: text }, headers: responseHeaders, requestId: id };
1227
+ if (opts.skip?.length) {
1228
+ if (opts.skip.includes(def.category) || opts.skip.includes(def.id)) return false;
924
1229
  }
925
- }
926
- try {
927
- return { statusCode: res.statusCode, body: JSON.parse(text), headers: responseHeaders, requestId: id };
928
- } catch {
929
- return { statusCode: res.statusCode, body: { _raw: text }, headers: responseHeaders, requestId: id };
930
- }
931
- }
932
- async function mcpNotification(backendUrl, method, params, extraHeaders, timeout) {
933
- const headers = {
934
- "Content-Type": "application/json",
935
- Accept: "application/json, text/event-stream",
936
- ...extraHeaders
937
- };
938
- const res = await request(backendUrl, {
939
- method: "POST",
940
- headers,
941
- body: JSON.stringify({ jsonrpc: "2.0", method, ...params ? { params } : {} }),
942
- signal: AbortSignal.timeout(timeout)
1230
+ return true;
943
1231
  });
944
- await res.body.text();
945
- const responseHeaders = {};
946
- for (const [k, v] of Object.entries(res.headers)) {
947
- if (typeof v === "string") responseHeaders[k] = v;
948
- }
949
- return { statusCode: res.statusCode, headers: responseHeaders };
950
1232
  }
951
- async function runComplianceSuite(url, options = {}) {
952
- try {
953
- const parsed = new URL(url);
954
- if (!["http:", "https:"].includes(parsed.protocol)) {
955
- throw new Error("Only HTTP and HTTPS URLs are supported");
1233
+ async function runComplianceSuite(target, options = {}) {
1234
+ const resolvedTarget = typeof target === "string" ? { type: "http", url: target, headers: options.headers } : target;
1235
+ if (resolvedTarget.type === "http") {
1236
+ try {
1237
+ const parsed = new URL(resolvedTarget.url);
1238
+ if (!["http:", "https:"].includes(parsed.protocol)) {
1239
+ throw new Error("Only HTTP and HTTPS URLs are supported");
1240
+ }
1241
+ } catch (e) {
1242
+ if (e instanceof Error && e.message.includes("Only HTTP")) throw e;
1243
+ throw new Error(`Invalid URL: ${resolvedTarget.url}`);
956
1244
  }
957
- } catch (e) {
958
- if (e instanceof Error && e.message.includes("Only HTTP")) throw e;
959
- throw new Error(`Invalid URL: ${url}`);
1245
+ } else if (!resolvedTarget.command) {
1246
+ throw new Error("stdio target requires a command");
960
1247
  }
961
- const backendUrl = url;
1248
+ const transport = resolvedTarget.type === "http" ? createHttpTransport({
1249
+ url: resolvedTarget.url,
1250
+ headers: resolvedTarget.headers ?? options.headers
1251
+ }) : createStdioTransport({
1252
+ command: resolvedTarget.command,
1253
+ args: resolvedTarget.args,
1254
+ env: resolvedTarget.env,
1255
+ cwd: resolvedTarget.cwd,
1256
+ verbose: resolvedTarget.verbose
1257
+ });
1258
+ const backendUrl = resolvedTarget.type === "http" ? resolvedTarget.url : "";
1259
+ const userHeaders = resolvedTarget.type === "http" ? resolvedTarget.headers ?? options.headers ?? {} : {};
1260
+ const displayUrl = resolvedTarget.type === "http" ? resolvedTarget.url : `stdio:${resolvedTarget.command}${resolvedTarget.args?.length ? ` ${resolvedTarget.args.join(" ")}` : ""}`;
962
1261
  let serverReachable = true;
963
- try {
964
- const preflight = await request(backendUrl, {
965
- method: "POST",
966
- headers: {
967
- "Content-Type": "application/json",
968
- Accept: "application/json, text/event-stream",
969
- ...options.headers
970
- },
971
- body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
972
- signal: AbortSignal.timeout(Math.min(options.timeout || 15e3, 1e4))
973
- });
974
- await preflight.body.text();
975
- } catch {
976
- serverReachable = false;
1262
+ if (resolvedTarget.type === "http") {
1263
+ try {
1264
+ const preflightTimeout = options.preflightTimeout ?? Math.min(options.timeout || 15e3, 1e4);
1265
+ const preflight = await request2(resolvedTarget.url, {
1266
+ method: "POST",
1267
+ headers: {
1268
+ "Content-Type": "application/json",
1269
+ Accept: "application/json, text/event-stream",
1270
+ ...userHeaders
1271
+ },
1272
+ body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
1273
+ signal: AbortSignal.timeout(preflightTimeout)
1274
+ });
1275
+ await preflight.body.text();
1276
+ } catch {
1277
+ serverReachable = false;
1278
+ }
977
1279
  }
978
1280
  const tests = [];
979
1281
  const warnings = [];
980
1282
  if (!serverReachable) {
981
1283
  warnings.push(
982
- `Server at ${url} is unreachable \u2014 all tests will fail. Check the URL and ensure the server is running.`
1284
+ `Server at ${displayUrl} is unreachable \u2014 all tests will fail. Check the URL or command and ensure the server is running.`
983
1285
  );
984
1286
  }
985
1287
  const nextId = createIdCounter(1e3);
@@ -987,15 +1289,35 @@ async function runComplianceSuite(url, options = {}) {
987
1289
  const retries = options.retries || 0;
988
1290
  let sessionId = null;
989
1291
  let negotiatedProtocolVersion = null;
990
- const userHeaders = options.headers || {};
991
1292
  function buildHeaders() {
992
1293
  const h = { ...userHeaders };
993
1294
  if (sessionId) h["mcp-session-id"] = sessionId;
994
1295
  if (negotiatedProtocolVersion) h["mcp-protocol-version"] = negotiatedProtocolVersion;
995
1296
  return h;
996
1297
  }
1298
+ async function mcpRequest(_backendUrl, method, params, idCounter, extraHeaders, timeoutMs) {
1299
+ const res = await transport.request(method, params, idCounter, {
1300
+ timeout: timeoutMs,
1301
+ headers: extraHeaders
1302
+ });
1303
+ return {
1304
+ statusCode: res.statusCode ?? 200,
1305
+ body: res.body,
1306
+ headers: res.headers ?? {},
1307
+ requestId: res.requestId
1308
+ };
1309
+ }
1310
+ async function mcpNotification(_backendUrl, method, params, extraHeaders, timeoutMs) {
1311
+ const res = await transport.notify(method, params, {
1312
+ timeout: timeoutMs,
1313
+ headers: extraHeaders
1314
+ });
1315
+ return { statusCode: res.statusCode ?? 200, headers: res.headers ?? {} };
1316
+ }
997
1317
  const rpc = (method, params) => mcpRequest(backendUrl, method, params, nextId, buildHeaders(), timeout);
998
1318
  function shouldRun(id, category) {
1319
+ const def = TEST_DEFINITIONS_MAP.get(id);
1320
+ if (!supportsTransport(def, transport.kind)) return false;
999
1321
  if (options.only && options.only.length > 0) {
1000
1322
  return options.only.includes(category) || options.only.includes(id);
1001
1323
  }
@@ -1050,7 +1372,7 @@ async function runComplianceSuite(url, options = {}) {
1050
1372
  true,
1051
1373
  "basic/transports#streamable-http",
1052
1374
  async () => {
1053
- const res = await request(backendUrl, {
1375
+ const res = await request2(backendUrl, {
1054
1376
  method: "POST",
1055
1377
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...userHeaders },
1056
1378
  body: JSON.stringify({ jsonrpc: "2.0", id: 99901, method: "ping" }),
@@ -1082,7 +1404,7 @@ async function runComplianceSuite(url, options = {}) {
1082
1404
  true,
1083
1405
  "basic/transports#streamable-http",
1084
1406
  async () => {
1085
- const res = await request(backendUrl, {
1407
+ const res = await request2(backendUrl, {
1086
1408
  method: "POST",
1087
1409
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...userHeaders },
1088
1410
  body: JSON.stringify({ jsonrpc: "2.0", id: 99902, method: "ping" }),
@@ -1102,7 +1424,7 @@ async function runComplianceSuite(url, options = {}) {
1102
1424
  false,
1103
1425
  "basic/transports#streamable-http",
1104
1426
  async () => {
1105
- const res = await request(backendUrl, {
1427
+ const res = await request2(backendUrl, {
1106
1428
  method: "POST",
1107
1429
  headers: { "Content-Type": "text/plain", Accept: "application/json, text/event-stream", ...userHeaders },
1108
1430
  body: JSON.stringify({ jsonrpc: "2.0", id: 99905, method: "ping" }),
@@ -1129,7 +1451,7 @@ async function runComplianceSuite(url, options = {}) {
1129
1451
  "basic/transports#streamable-http",
1130
1452
  async () => {
1131
1453
  const getHeaders = { Accept: "text/event-stream", ...buildHeaders() };
1132
- const res = await request(backendUrl, {
1454
+ const res = await request2(backendUrl, {
1133
1455
  method: "GET",
1134
1456
  headers: getHeaders,
1135
1457
  signal: AbortSignal.timeout(timeout)
@@ -1166,7 +1488,7 @@ async function runComplianceSuite(url, options = {}) {
1166
1488
  true,
1167
1489
  "basic/transports#streamable-http",
1168
1490
  async () => {
1169
- const res = await request(backendUrl, {
1491
+ const res = await request2(backendUrl, {
1170
1492
  method: "POST",
1171
1493
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...userHeaders },
1172
1494
  body: JSON.stringify([
@@ -1206,8 +1528,14 @@ async function runComplianceSuite(url, options = {}) {
1206
1528
  serverInfo.version = result.serverInfo?.version || null;
1207
1529
  serverInfo.capabilities = result.capabilities || {};
1208
1530
  const sid = initRes.headers["mcp-session-id"];
1209
- if (sid) sessionId = sid;
1210
- if (result.protocolVersion) negotiatedProtocolVersion = result.protocolVersion;
1531
+ if (sid) {
1532
+ sessionId = sid;
1533
+ transport.setSessionId(sid);
1534
+ }
1535
+ if (result.protocolVersion) {
1536
+ negotiatedProtocolVersion = result.protocolVersion;
1537
+ transport.setProtocolVersion(result.protocolVersion);
1538
+ }
1211
1539
  }
1212
1540
  } catch {
1213
1541
  }
@@ -1215,6 +1543,9 @@ async function runComplianceSuite(url, options = {}) {
1215
1543
  await mcpNotification(backendUrl, "notifications/initialized", void 0, buildHeaders(), timeout);
1216
1544
  } catch {
1217
1545
  }
1546
+ const hasTools = !!serverInfo.capabilities.tools;
1547
+ const hasResources = !!serverInfo.capabilities.resources;
1548
+ const hasPrompts = !!serverInfo.capabilities.prompts;
1218
1549
  await test(
1219
1550
  "lifecycle-init",
1220
1551
  "Initialize handshake",
@@ -1362,7 +1693,7 @@ async function runComplianceSuite(url, options = {}) {
1362
1693
  await test("lifecycle-string-id", "Supports string request IDs", "lifecycle", false, "basic", async () => {
1363
1694
  const stringId = "compliance-test-string-id";
1364
1695
  const body = JSON.stringify({ jsonrpc: "2.0", id: stringId, method: "ping", params: {} });
1365
- const res = await request(backendUrl, {
1696
+ const res = await request2(backendUrl, {
1366
1697
  method: "POST",
1367
1698
  headers: {
1368
1699
  "Content-Type": "application/json",
@@ -1577,7 +1908,7 @@ async function runComplianceSuite(url, options = {}) {
1577
1908
  }
1578
1909
  });
1579
1910
  try {
1580
- const res = await request(backendUrl, {
1911
+ const res = await request2(backendUrl, {
1581
1912
  method: "POST",
1582
1913
  headers: {
1583
1914
  "Content-Type": "application/json",
@@ -1625,7 +1956,7 @@ async function runComplianceSuite(url, options = {}) {
1625
1956
  false,
1626
1957
  "basic/transports#streamable-http",
1627
1958
  async () => {
1628
- const res = await request(backendUrl, {
1959
+ const res = await request2(backendUrl, {
1629
1960
  method: "POST",
1630
1961
  headers: {
1631
1962
  "Content-Type": "application/json",
@@ -1717,7 +2048,7 @@ async function runComplianceSuite(url, options = {}) {
1717
2048
  if (!sessionId) {
1718
2049
  return { passed: true, details: "No session ID \u2014 server-initiated messages not applicable" };
1719
2050
  }
1720
- const res = await request(backendUrl, {
2051
+ const res = await request2(backendUrl, {
1721
2052
  method: "GET",
1722
2053
  headers: { Accept: "text/event-stream", ...buildHeaders() },
1723
2054
  signal: AbortSignal.timeout(Math.min(timeout, 3e3))
@@ -1752,7 +2083,7 @@ async function runComplianceSuite(url, options = {}) {
1752
2083
  async () => {
1753
2084
  const ids = [createIdCounter(99930)(), createIdCounter(99931)(), createIdCounter(99932)()];
1754
2085
  const promises = ids.map(
1755
- (id) => request(backendUrl, {
2086
+ (id) => request2(backendUrl, {
1756
2087
  method: "POST",
1757
2088
  headers: {
1758
2089
  "Content-Type": "application/json",
@@ -1798,7 +2129,7 @@ async function runComplianceSuite(url, options = {}) {
1798
2129
  false,
1799
2130
  "basic/transports#streamable-http",
1800
2131
  async () => {
1801
- const res = await request(backendUrl, {
2132
+ const res = await request2(backendUrl, {
1802
2133
  method: "POST",
1803
2134
  headers: {
1804
2135
  "Content-Type": "application/json",
@@ -1827,7 +2158,6 @@ async function runComplianceSuite(url, options = {}) {
1827
2158
  return { passed: true, details: "SSE response empty or no data fields \u2014 check not applicable" };
1828
2159
  }
1829
2160
  );
1830
- const hasTools = !!serverInfo.capabilities.tools;
1831
2161
  let cachedToolsList = null;
1832
2162
  await test(
1833
2163
  "tools-list",
@@ -2071,8 +2401,8 @@ async function runComplianceSuite(url, options = {}) {
2071
2401
  }
2072
2402
  );
2073
2403
  }
2074
- const hasResources = !!serverInfo.capabilities.resources;
2075
- const hasSubscribe = !!serverInfo.capabilities.resources?.subscribe;
2404
+ const resourcesCap = serverInfo.capabilities.resources;
2405
+ const hasSubscribe = !!(typeof resourcesCap === "object" && resourcesCap !== null && "subscribe" in resourcesCap && resourcesCap.subscribe);
2076
2406
  if (hasResources) {
2077
2407
  let cachedResourcesList = null;
2078
2408
  await test(
@@ -2234,7 +2564,6 @@ async function runComplianceSuite(url, options = {}) {
2234
2564
  );
2235
2565
  }
2236
2566
  }
2237
- const hasPrompts = !!serverInfo.capabilities.prompts;
2238
2567
  if (hasPrompts) {
2239
2568
  let cachedPromptsList = null;
2240
2569
  await test(
@@ -2349,7 +2678,7 @@ async function runComplianceSuite(url, options = {}) {
2349
2678
  }
2350
2679
  );
2351
2680
  await test("error-invalid-jsonrpc", "Handles malformed JSON-RPC", "errors", true, "basic", async () => {
2352
- const res = await request(backendUrl, {
2681
+ const res = await request2(backendUrl, {
2353
2682
  method: "POST",
2354
2683
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2355
2684
  body: JSON.stringify({ not: "a valid jsonrpc message" }),
@@ -2372,7 +2701,7 @@ async function runComplianceSuite(url, options = {}) {
2372
2701
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected JSON-RPC error or 4xx status` };
2373
2702
  });
2374
2703
  await test("error-invalid-json", "Handles invalid JSON body", "errors", false, "basic", async () => {
2375
- const res = await request(backendUrl, {
2704
+ const res = await request2(backendUrl, {
2376
2705
  method: "POST",
2377
2706
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2378
2707
  body: "{this is not valid json!!!",
@@ -2410,7 +2739,7 @@ async function runComplianceSuite(url, options = {}) {
2410
2739
  }
2411
2740
  );
2412
2741
  await test("error-parse-code", "Returns -32700 for invalid JSON", "errors", false, "basic", async () => {
2413
- const res = await request(backendUrl, {
2742
+ const res = await request2(backendUrl, {
2414
2743
  method: "POST",
2415
2744
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2416
2745
  body: "<<<not json>>>",
@@ -2433,7 +2762,7 @@ async function runComplianceSuite(url, options = {}) {
2433
2762
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32700` };
2434
2763
  });
2435
2764
  await test("error-invalid-request-code", "Returns -32600 for invalid request", "errors", false, "basic", async () => {
2436
- const res = await request(backendUrl, {
2765
+ const res = await request2(backendUrl, {
2437
2766
  method: "POST",
2438
2767
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2439
2768
  body: JSON.stringify({ jsonrpc: "2.0", id: 99999 }),
@@ -2598,13 +2927,13 @@ async function runComplianceSuite(url, options = {}) {
2598
2927
  }
2599
2928
  );
2600
2929
  await test("security-tls-required", "Enforces HTTPS/TLS", "security", false, "basic/authorization", async () => {
2601
- const parsedUrl = new URL(url);
2930
+ const parsedUrl = new URL(backendUrl);
2602
2931
  if (parsedUrl.protocol !== "https:") {
2603
2932
  return { passed: false, details: `Server URL uses ${parsedUrl.protocol} \u2014 production servers should use HTTPS` };
2604
2933
  }
2605
- const httpUrl = url.replace(/^https:/, "http:");
2934
+ const httpUrl = backendUrl.replace(/^https:/, "http:");
2606
2935
  try {
2607
- const res = await request(httpUrl, {
2936
+ const res = await request2(httpUrl, {
2608
2937
  method: "POST",
2609
2938
  headers: { "Content-Type": "application/json", Accept: "application/json" },
2610
2939
  body: JSON.stringify({ jsonrpc: "2.0", id: 99950, method: "ping" }),
@@ -2697,10 +3026,10 @@ async function runComplianceSuite(url, options = {}) {
2697
3026
  if (!hasAuth) {
2698
3027
  return { passed: true, details: "Skipped: server does not require auth" };
2699
3028
  }
2700
- const parsedUrl = new URL(url);
3029
+ const parsedUrl = new URL(backendUrl);
2701
3030
  const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
2702
3031
  try {
2703
- const res = await request(prmUrl, {
3032
+ const res = await request2(prmUrl, {
2704
3033
  method: "GET",
2705
3034
  headers: { Accept: "application/json" },
2706
3035
  signal: AbortSignal.timeout(Math.min(timeout, 5e3))
@@ -2725,7 +3054,7 @@ async function runComplianceSuite(url, options = {}) {
2725
3054
  }
2726
3055
  const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
2727
3056
  try {
2728
- const legacyRes = await request(legacyUrl, {
3057
+ const legacyRes = await request2(legacyUrl, {
2729
3058
  method: "GET",
2730
3059
  headers: { Accept: "application/json" },
2731
3060
  signal: AbortSignal.timeout(Math.min(timeout, 5e3))
@@ -2772,7 +3101,7 @@ async function runComplianceSuite(url, options = {}) {
2772
3101
  if (!token) {
2773
3102
  return { passed: true, details: "Skipped: could not extract token from auth header" };
2774
3103
  }
2775
- const uriWithToken = `${url}${url.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
3104
+ const uriWithToken = `${backendUrl}${backendUrl.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
2776
3105
  try {
2777
3106
  const noAuthHeaders = {};
2778
3107
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
@@ -2800,7 +3129,7 @@ async function runComplianceSuite(url, options = {}) {
2800
3129
  "basic/transports#streamable-http",
2801
3130
  async () => {
2802
3131
  try {
2803
- const res = await request(backendUrl, {
3132
+ const res = await request2(backendUrl, {
2804
3133
  method: "OPTIONS",
2805
3134
  headers: {
2806
3135
  Origin: "https://evil.example.com",
@@ -2837,7 +3166,7 @@ async function runComplianceSuite(url, options = {}) {
2837
3166
  "basic/transports#streamable-http",
2838
3167
  async () => {
2839
3168
  try {
2840
- const res = await request(backendUrl, {
3169
+ const res = await request2(backendUrl, {
2841
3170
  method: "POST",
2842
3171
  headers: {
2843
3172
  "Content-Type": "application/json",
@@ -3007,7 +3336,7 @@ async function runComplianceSuite(url, options = {}) {
3007
3336
  async () => {
3008
3337
  const largeValue = "A".repeat(1048576);
3009
3338
  try {
3010
- const res = await request(backendUrl, {
3339
+ const res = await request2(backendUrl, {
3011
3340
  method: "POST",
3012
3341
  headers: {
3013
3342
  "Content-Type": "application/json",
@@ -3209,7 +3538,7 @@ async function runComplianceSuite(url, options = {}) {
3209
3538
  ];
3210
3539
  for (const payload of errorPayloads) {
3211
3540
  try {
3212
- const res = await request(backendUrl, {
3541
+ const res = await request2(backendUrl, {
3213
3542
  method: "POST",
3214
3543
  headers: {
3215
3544
  "Content-Type": "application/json",
@@ -3248,7 +3577,7 @@ async function runComplianceSuite(url, options = {}) {
3248
3577
  "basic",
3249
3578
  async () => {
3250
3579
  try {
3251
- const res = await request(backendUrl, {
3580
+ const res = await request2(backendUrl, {
3252
3581
  method: "POST",
3253
3582
  headers: {
3254
3583
  "Content-Type": "application/json",
@@ -3312,7 +3641,7 @@ async function runComplianceSuite(url, options = {}) {
3312
3641
  "basic/transports#streamable-http",
3313
3642
  async () => {
3314
3643
  const deleteHeaders = { ...buildHeaders() };
3315
- const res = await request(backendUrl, {
3644
+ const res = await request2(backendUrl, {
3316
3645
  method: "DELETE",
3317
3646
  headers: deleteHeaders,
3318
3647
  signal: AbortSignal.timeout(timeout)
@@ -3353,17 +3682,81 @@ async function runComplianceSuite(url, options = {}) {
3353
3682
  return { passed: false, details: `HTTP ${res.statusCode}` };
3354
3683
  }
3355
3684
  );
3356
- const MAX_WARNINGS = 50;
3685
+ await test(
3686
+ "stdio-framing",
3687
+ "Newline-delimited JSON framing",
3688
+ "transport",
3689
+ true,
3690
+ "basic/transports#stdio",
3691
+ async () => {
3692
+ const results = await Promise.all(
3693
+ Array.from({ length: 5 }, () => rpc("ping").catch((e) => ({ _err: e.message })))
3694
+ );
3695
+ const failed = results.filter((r) => "_err" in r);
3696
+ if (failed.length) {
3697
+ return { passed: false, details: `${failed.length}/5 rapid pings failed \u2014 framing likely broken` };
3698
+ }
3699
+ return { passed: true, details: "5/5 rapid pings returned cleanly" };
3700
+ }
3701
+ );
3702
+ await test("stdio-unicode", "UTF-8 unicode roundtrip", "transport", false, "basic/transports#stdio", async () => {
3703
+ const probe = "h\xE9llo \u4E16\u754C \u{1F680}";
3704
+ if (hasTools && toolNames.length > 0) {
3705
+ try {
3706
+ const res2 = await rpc("tools/call", {
3707
+ name: toolNames[0],
3708
+ arguments: { message: probe, text: probe, input: probe, query: probe }
3709
+ });
3710
+ const serialized = JSON.stringify(res2.body);
3711
+ if (serialized.includes(probe)) {
3712
+ return { passed: true, details: "Unicode string round-tripped through tool call" };
3713
+ }
3714
+ return { passed: true, details: "Tool echoed something, but not the exact probe \u2014 likely still UTF-8-safe" };
3715
+ } catch (err) {
3716
+ return { passed: false, details: `tools/call threw \u2014 ${err instanceof Error ? err.message : String(err)}` };
3717
+ }
3718
+ }
3719
+ const res = await rpc("tools/list");
3720
+ if (res.body.error) {
3721
+ return { passed: false, details: "tools/list returned error" };
3722
+ }
3723
+ return { passed: true, details: "tools/list returned successfully (no tools to probe with unicode)" };
3724
+ });
3725
+ await test(
3726
+ "stdio-unknown-method-recovers",
3727
+ "Recovers after unknown method",
3728
+ "transport",
3729
+ false,
3730
+ "basic/transports#stdio",
3731
+ async () => {
3732
+ const errRes = await rpc("this/method/does/not/exist-xyzzy");
3733
+ const errBody = errRes.body;
3734
+ if (!errBody.error) {
3735
+ return { passed: false, details: "Unknown method did not produce a JSON-RPC error" };
3736
+ }
3737
+ const okRes = await rpc("ping");
3738
+ const okBody = okRes.body;
3739
+ if (okBody.error) {
3740
+ return {
3741
+ passed: false,
3742
+ details: "Server responded with error to ping after unknown method \u2014 may have desynced"
3743
+ };
3744
+ }
3745
+ return { passed: true, details: "Unknown method returned JSON-RPC error; subsequent ping succeeded" };
3746
+ }
3747
+ );
3748
+ const MAX_WARNINGS = 100;
3357
3749
  if (warnings.length > MAX_WARNINGS) {
3358
3750
  const truncated = warnings.length - MAX_WARNINGS;
3359
3751
  warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
3360
3752
  }
3361
3753
  const { score, grade, overall, summary, categories } = computeScore(tests);
3362
- const badge = generateBadge(url);
3754
+ const badge = generateBadge(displayUrl);
3755
+ await transport.close();
3363
3756
  return {
3364
3757
  specVersion: SPEC_VERSION,
3365
3758
  toolVersion: TOOL_VERSION,
3366
- url,
3759
+ url: displayUrl,
3367
3760
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3368
3761
  score,
3369
3762
  grade,
@@ -3387,9 +3780,10 @@ export {
3387
3780
  generateBadge,
3388
3781
  computeGrade,
3389
3782
  computeScore,
3783
+ parseSSEResponse,
3390
3784
  TEST_DEFINITIONS,
3391
3785
  SPEC_VERSION,
3392
3786
  SPEC_BASE,
3393
- parseSSEResponse,
3787
+ previewTests,
3394
3788
  runComplianceSuite
3395
3789
  };