@yawlabs/mcp-compliance 0.8.1 → 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(options.preflightTimeout ?? 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,7 +2401,6 @@ async function runComplianceSuite(url, options = {}) {
2071
2401
  }
2072
2402
  );
2073
2403
  }
2074
- const hasResources = !!serverInfo.capabilities.resources;
2075
2404
  const resourcesCap = serverInfo.capabilities.resources;
2076
2405
  const hasSubscribe = !!(typeof resourcesCap === "object" && resourcesCap !== null && "subscribe" in resourcesCap && resourcesCap.subscribe);
2077
2406
  if (hasResources) {
@@ -2235,7 +2564,6 @@ async function runComplianceSuite(url, options = {}) {
2235
2564
  );
2236
2565
  }
2237
2566
  }
2238
- const hasPrompts = !!serverInfo.capabilities.prompts;
2239
2567
  if (hasPrompts) {
2240
2568
  let cachedPromptsList = null;
2241
2569
  await test(
@@ -2350,7 +2678,7 @@ async function runComplianceSuite(url, options = {}) {
2350
2678
  }
2351
2679
  );
2352
2680
  await test("error-invalid-jsonrpc", "Handles malformed JSON-RPC", "errors", true, "basic", async () => {
2353
- const res = await request(backendUrl, {
2681
+ const res = await request2(backendUrl, {
2354
2682
  method: "POST",
2355
2683
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2356
2684
  body: JSON.stringify({ not: "a valid jsonrpc message" }),
@@ -2373,7 +2701,7 @@ async function runComplianceSuite(url, options = {}) {
2373
2701
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected JSON-RPC error or 4xx status` };
2374
2702
  });
2375
2703
  await test("error-invalid-json", "Handles invalid JSON body", "errors", false, "basic", async () => {
2376
- const res = await request(backendUrl, {
2704
+ const res = await request2(backendUrl, {
2377
2705
  method: "POST",
2378
2706
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2379
2707
  body: "{this is not valid json!!!",
@@ -2411,7 +2739,7 @@ async function runComplianceSuite(url, options = {}) {
2411
2739
  }
2412
2740
  );
2413
2741
  await test("error-parse-code", "Returns -32700 for invalid JSON", "errors", false, "basic", async () => {
2414
- const res = await request(backendUrl, {
2742
+ const res = await request2(backendUrl, {
2415
2743
  method: "POST",
2416
2744
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2417
2745
  body: "<<<not json>>>",
@@ -2434,7 +2762,7 @@ async function runComplianceSuite(url, options = {}) {
2434
2762
  return { passed: false, details: `HTTP ${res.statusCode} \u2014 expected error code -32700` };
2435
2763
  });
2436
2764
  await test("error-invalid-request-code", "Returns -32600 for invalid request", "errors", false, "basic", async () => {
2437
- const res = await request(backendUrl, {
2765
+ const res = await request2(backendUrl, {
2438
2766
  method: "POST",
2439
2767
  headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...buildHeaders() },
2440
2768
  body: JSON.stringify({ jsonrpc: "2.0", id: 99999 }),
@@ -2599,13 +2927,13 @@ async function runComplianceSuite(url, options = {}) {
2599
2927
  }
2600
2928
  );
2601
2929
  await test("security-tls-required", "Enforces HTTPS/TLS", "security", false, "basic/authorization", async () => {
2602
- const parsedUrl = new URL(url);
2930
+ const parsedUrl = new URL(backendUrl);
2603
2931
  if (parsedUrl.protocol !== "https:") {
2604
2932
  return { passed: false, details: `Server URL uses ${parsedUrl.protocol} \u2014 production servers should use HTTPS` };
2605
2933
  }
2606
- const httpUrl = url.replace(/^https:/, "http:");
2934
+ const httpUrl = backendUrl.replace(/^https:/, "http:");
2607
2935
  try {
2608
- const res = await request(httpUrl, {
2936
+ const res = await request2(httpUrl, {
2609
2937
  method: "POST",
2610
2938
  headers: { "Content-Type": "application/json", Accept: "application/json" },
2611
2939
  body: JSON.stringify({ jsonrpc: "2.0", id: 99950, method: "ping" }),
@@ -2698,10 +3026,10 @@ async function runComplianceSuite(url, options = {}) {
2698
3026
  if (!hasAuth) {
2699
3027
  return { passed: true, details: "Skipped: server does not require auth" };
2700
3028
  }
2701
- const parsedUrl = new URL(url);
3029
+ const parsedUrl = new URL(backendUrl);
2702
3030
  const prmUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-protected-resource`;
2703
3031
  try {
2704
- const res = await request(prmUrl, {
3032
+ const res = await request2(prmUrl, {
2705
3033
  method: "GET",
2706
3034
  headers: { Accept: "application/json" },
2707
3035
  signal: AbortSignal.timeout(Math.min(timeout, 5e3))
@@ -2726,7 +3054,7 @@ async function runComplianceSuite(url, options = {}) {
2726
3054
  }
2727
3055
  const legacyUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/oauth-authorization-server`;
2728
3056
  try {
2729
- const legacyRes = await request(legacyUrl, {
3057
+ const legacyRes = await request2(legacyUrl, {
2730
3058
  method: "GET",
2731
3059
  headers: { Accept: "application/json" },
2732
3060
  signal: AbortSignal.timeout(Math.min(timeout, 5e3))
@@ -2773,7 +3101,7 @@ async function runComplianceSuite(url, options = {}) {
2773
3101
  if (!token) {
2774
3102
  return { passed: true, details: "Skipped: could not extract token from auth header" };
2775
3103
  }
2776
- const uriWithToken = `${url}${url.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
3104
+ const uriWithToken = `${backendUrl}${backendUrl.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(token)}`;
2777
3105
  try {
2778
3106
  const noAuthHeaders = {};
2779
3107
  if (sessionId) noAuthHeaders["mcp-session-id"] = sessionId;
@@ -2801,7 +3129,7 @@ async function runComplianceSuite(url, options = {}) {
2801
3129
  "basic/transports#streamable-http",
2802
3130
  async () => {
2803
3131
  try {
2804
- const res = await request(backendUrl, {
3132
+ const res = await request2(backendUrl, {
2805
3133
  method: "OPTIONS",
2806
3134
  headers: {
2807
3135
  Origin: "https://evil.example.com",
@@ -2838,7 +3166,7 @@ async function runComplianceSuite(url, options = {}) {
2838
3166
  "basic/transports#streamable-http",
2839
3167
  async () => {
2840
3168
  try {
2841
- const res = await request(backendUrl, {
3169
+ const res = await request2(backendUrl, {
2842
3170
  method: "POST",
2843
3171
  headers: {
2844
3172
  "Content-Type": "application/json",
@@ -3008,7 +3336,7 @@ async function runComplianceSuite(url, options = {}) {
3008
3336
  async () => {
3009
3337
  const largeValue = "A".repeat(1048576);
3010
3338
  try {
3011
- const res = await request(backendUrl, {
3339
+ const res = await request2(backendUrl, {
3012
3340
  method: "POST",
3013
3341
  headers: {
3014
3342
  "Content-Type": "application/json",
@@ -3210,7 +3538,7 @@ async function runComplianceSuite(url, options = {}) {
3210
3538
  ];
3211
3539
  for (const payload of errorPayloads) {
3212
3540
  try {
3213
- const res = await request(backendUrl, {
3541
+ const res = await request2(backendUrl, {
3214
3542
  method: "POST",
3215
3543
  headers: {
3216
3544
  "Content-Type": "application/json",
@@ -3249,7 +3577,7 @@ async function runComplianceSuite(url, options = {}) {
3249
3577
  "basic",
3250
3578
  async () => {
3251
3579
  try {
3252
- const res = await request(backendUrl, {
3580
+ const res = await request2(backendUrl, {
3253
3581
  method: "POST",
3254
3582
  headers: {
3255
3583
  "Content-Type": "application/json",
@@ -3313,7 +3641,7 @@ async function runComplianceSuite(url, options = {}) {
3313
3641
  "basic/transports#streamable-http",
3314
3642
  async () => {
3315
3643
  const deleteHeaders = { ...buildHeaders() };
3316
- const res = await request(backendUrl, {
3644
+ const res = await request2(backendUrl, {
3317
3645
  method: "DELETE",
3318
3646
  headers: deleteHeaders,
3319
3647
  signal: AbortSignal.timeout(timeout)
@@ -3354,17 +3682,81 @@ async function runComplianceSuite(url, options = {}) {
3354
3682
  return { passed: false, details: `HTTP ${res.statusCode}` };
3355
3683
  }
3356
3684
  );
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
+ );
3357
3748
  const MAX_WARNINGS = 100;
3358
3749
  if (warnings.length > MAX_WARNINGS) {
3359
3750
  const truncated = warnings.length - MAX_WARNINGS;
3360
3751
  warnings.splice(MAX_WARNINGS, truncated, `... and ${truncated} more warning(s) suppressed`);
3361
3752
  }
3362
3753
  const { score, grade, overall, summary, categories } = computeScore(tests);
3363
- const badge = generateBadge(url);
3754
+ const badge = generateBadge(displayUrl);
3755
+ await transport.close();
3364
3756
  return {
3365
3757
  specVersion: SPEC_VERSION,
3366
3758
  toolVersion: TOOL_VERSION,
3367
- url,
3759
+ url: displayUrl,
3368
3760
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3369
3761
  score,
3370
3762
  grade,
@@ -3388,9 +3780,10 @@ export {
3388
3780
  generateBadge,
3389
3781
  computeGrade,
3390
3782
  computeScore,
3783
+ parseSSEResponse,
3391
3784
  TEST_DEFINITIONS,
3392
3785
  SPEC_VERSION,
3393
3786
  SPEC_BASE,
3394
- parseSSEResponse,
3787
+ previewTests,
3395
3788
  runComplianceSuite
3396
3789
  };