@yawlabs/mcp-compliance 0.13.1 → 0.13.3

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.
@@ -37,12 +37,28 @@ function computeScore(tests) {
37
37
  const failed = total - passed;
38
38
  const requiredTests = tests.filter((t) => t.required);
39
39
  const requiredPassed = requiredTests.filter((t) => t.passed).length;
40
- const requiredScore = requiredTests.length > 0 ? requiredPassed / requiredTests.length * 70 : 70;
41
40
  const optionalTests = tests.filter((t) => !t.required);
42
41
  const optionalPassed = optionalTests.filter((t) => t.passed).length;
43
- const optionalScore = optionalTests.length > 0 ? optionalPassed / optionalTests.length * 30 : 30;
44
- const score = Math.round(requiredScore + optionalScore);
45
- const overall = requiredPassed === requiredTests.length ? passed === total ? "pass" : "partial" : "fail";
42
+ let score;
43
+ if (total === 0) {
44
+ score = 0;
45
+ } else if (requiredTests.length === 0) {
46
+ score = Math.round(optionalPassed / optionalTests.length * 100);
47
+ } else if (optionalTests.length === 0) {
48
+ score = Math.round(requiredPassed / requiredTests.length * 100);
49
+ } else {
50
+ score = Math.round(requiredPassed / requiredTests.length * 70 + optionalPassed / optionalTests.length * 30);
51
+ }
52
+ let overall;
53
+ if (total === 0) {
54
+ overall = "fail";
55
+ } else if (requiredPassed < requiredTests.length) {
56
+ overall = "fail";
57
+ } else if (passed === total) {
58
+ overall = "pass";
59
+ } else {
60
+ overall = "partial";
61
+ }
46
62
  const categories = {};
47
63
  for (const t of tests) {
48
64
  if (!categories[t.category]) categories[t.category] = { passed: 0, total: 0 };
@@ -198,6 +214,13 @@ function createHttpTransport(opts) {
198
214
 
199
215
  // src/transport/stdio.ts
200
216
  import { spawn } from "child_process";
217
+ function exitDiagnostic(code, signal) {
218
+ if (signal) return `server terminated by signal ${signal} before completing the request`;
219
+ if (code === 0) {
220
+ return "server exited cleanly (code 0) before completing the request. This usually means the command is a one-shot CLI, not a long-running MCP stdio server. If the server needs a subcommand to start (e.g. `serve`, `mcp`, `start`), include it in the command.";
221
+ }
222
+ return `server crashed with exit code ${code} before completing the request`;
223
+ }
201
224
  function createStdioTransport(opts) {
202
225
  const { command, args = [], env, cwd, verbose = false } = opts;
203
226
  const stderrBufferSize = opts.stderrBufferSize ?? 64 * 1024;
@@ -237,8 +260,7 @@ function createStdioTransport(opts) {
237
260
  exited = true;
238
261
  exitCode = code;
239
262
  if (pending.size > 0) {
240
- const reason = signal ? `child exited (signal ${signal})` : `child exited with code ${code}`;
241
- rejectAllPending(new Error(reason));
263
+ rejectAllPending(new Error(exitDiagnostic(code, signal)));
242
264
  }
243
265
  });
244
266
  child.stdout?.setEncoding("utf8");
@@ -313,7 +335,7 @@ function createStdioTransport(opts) {
313
335
  }
314
336
  }
315
337
  if (exited) {
316
- throw new Error(annotateWithStderr(`stdio transport: child has exited (code ${exitCode})`));
338
+ throw new Error(annotateWithStderr(`stdio transport: ${exitDiagnostic(exitCode, null)}`));
317
339
  }
318
340
  if (spawnError) throw new Error(annotateWithStderr(`stdio transport: spawn failed \u2014 ${spawnError.message}`));
319
341
  const stdin = child.stdin;
@@ -4052,7 +4074,7 @@ async function runComplianceSuite(target, options = {}) {
4052
4074
  warnings.length = 0;
4053
4075
  warnings.push(...capped);
4054
4076
  const { score, grade, overall, summary, categories } = computeScore(tests);
4055
- const badge = generateBadge(displayUrl);
4077
+ const badge = displayUrl.startsWith("stdio:") ? { imageUrl: "", reportUrl: "", markdown: "", html: "" } : generateBadge(displayUrl);
4056
4078
  return {
4057
4079
  schemaVersion: REPORT_SCHEMA_VERSION,
4058
4080
  specVersion: SPEC_VERSION,
package/dist/index.js CHANGED
@@ -204,6 +204,13 @@ function createHttpTransport(opts) {
204
204
 
205
205
  // src/transport/stdio.ts
206
206
  import { spawn } from "child_process";
207
+ function exitDiagnostic(code, signal) {
208
+ if (signal) return `server terminated by signal ${signal} before completing the request`;
209
+ if (code === 0) {
210
+ return "server exited cleanly (code 0) before completing the request. This usually means the command is a one-shot CLI, not a long-running MCP stdio server. If the server needs a subcommand to start (e.g. `serve`, `mcp`, `start`), include it in the command.";
211
+ }
212
+ return `server crashed with exit code ${code} before completing the request`;
213
+ }
207
214
  function createStdioTransport(opts) {
208
215
  const { command, args = [], env, cwd, verbose = false } = opts;
209
216
  const stderrBufferSize = opts.stderrBufferSize ?? 64 * 1024;
@@ -243,8 +250,7 @@ function createStdioTransport(opts) {
243
250
  exited = true;
244
251
  exitCode = code;
245
252
  if (pending.size > 0) {
246
- const reason = signal ? `child exited (signal ${signal})` : `child exited with code ${code}`;
247
- rejectAllPending(new Error(reason));
253
+ rejectAllPending(new Error(exitDiagnostic(code, signal)));
248
254
  }
249
255
  });
250
256
  child.stdout?.setEncoding("utf8");
@@ -319,7 +325,7 @@ function createStdioTransport(opts) {
319
325
  }
320
326
  }
321
327
  if (exited) {
322
- throw new Error(annotateWithStderr(`stdio transport: child has exited (code ${exitCode})`));
328
+ throw new Error(annotateWithStderr(`stdio transport: ${exitDiagnostic(exitCode, null)}`));
323
329
  }
324
330
  if (spawnError) throw new Error(annotateWithStderr(`stdio transport: spawn failed \u2014 ${spawnError.message}`));
325
331
  const stdin = child.stdin;
@@ -740,12 +746,28 @@ function computeScore(tests) {
740
746
  const failed = total - passed;
741
747
  const requiredTests = tests.filter((t) => t.required);
742
748
  const requiredPassed = requiredTests.filter((t) => t.passed).length;
743
- const requiredScore = requiredTests.length > 0 ? requiredPassed / requiredTests.length * 70 : 70;
744
749
  const optionalTests = tests.filter((t) => !t.required);
745
750
  const optionalPassed = optionalTests.filter((t) => t.passed).length;
746
- const optionalScore = optionalTests.length > 0 ? optionalPassed / optionalTests.length * 30 : 30;
747
- const score = Math.round(requiredScore + optionalScore);
748
- const overall = requiredPassed === requiredTests.length ? passed === total ? "pass" : "partial" : "fail";
751
+ let score;
752
+ if (total === 0) {
753
+ score = 0;
754
+ } else if (requiredTests.length === 0) {
755
+ score = Math.round(optionalPassed / optionalTests.length * 100);
756
+ } else if (optionalTests.length === 0) {
757
+ score = Math.round(requiredPassed / requiredTests.length * 100);
758
+ } else {
759
+ score = Math.round(requiredPassed / requiredTests.length * 70 + optionalPassed / optionalTests.length * 30);
760
+ }
761
+ let overall;
762
+ if (total === 0) {
763
+ overall = "fail";
764
+ } else if (requiredPassed < requiredTests.length) {
765
+ overall = "fail";
766
+ } else if (passed === total) {
767
+ overall = "pass";
768
+ } else {
769
+ overall = "partial";
770
+ }
749
771
  const categories = {};
750
772
  for (const t of tests) {
751
773
  if (!categories[t.category]) categories[t.category] = { passed: 0, total: 0 };
@@ -4408,7 +4430,7 @@ async function runComplianceSuite(target, options = {}) {
4408
4430
  warnings.length = 0;
4409
4431
  warnings.push(...capped);
4410
4432
  const { score, grade, overall, summary, categories } = computeScore(tests);
4411
- const badge = generateBadge(displayUrl);
4433
+ const badge = displayUrl.startsWith("stdio:") ? { imageUrl: "", reportUrl: "", markdown: "", html: "" } : generateBadge(displayUrl);
4412
4434
  return {
4413
4435
  schemaVersion: REPORT_SCHEMA_VERSION,
4414
4436
  specVersion: SPEC_VERSION,
@@ -4956,7 +4978,7 @@ function formatGithub(report) {
4956
4978
  }
4957
4979
  function formatMarkdown(report) {
4958
4980
  const lines = [];
4959
- const gradeEmoji = { A: "\u{1F7E2}", B: "\u{1F7E2}", C: "\u{1F7E1}", D: "\u{1F7E0}", F: "\u{1F534}" };
4981
+ const gradeEmoji = { A: "\u{1F7E2}", B: "\u{1F535}", C: "\u{1F7E1}", D: "\u{1F7E0}", F: "\u{1F534}" };
4960
4982
  lines.push("# MCP Compliance Report");
4961
4983
  lines.push("");
4962
4984
  lines.push(
@@ -5139,6 +5161,20 @@ function formatHtml(report) {
5139
5161
  </html>`;
5140
5162
  }
5141
5163
 
5164
+ // src/stdio-split.ts
5165
+ function splitStdioTarget(s) {
5166
+ if (/["'`]/.test(s)) {
5167
+ throw new Error(
5168
+ "stdio command contains quote characters \u2014 pass the command and its args as separate tokens (e.g. `mcp-compliance test node dist/index.js serve`) instead of wrapping them in one quoted string."
5169
+ );
5170
+ }
5171
+ const tokens = s.trim().split(/\s+/).filter(Boolean);
5172
+ if (tokens.length === 0) {
5173
+ throw new Error("stdio command is empty");
5174
+ }
5175
+ return { command: tokens[0], args: tokens.slice(1) };
5176
+ }
5177
+
5142
5178
  // src/token-store.ts
5143
5179
  import { createHash as createHash2 } from "crypto";
5144
5180
  import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
@@ -5268,10 +5304,17 @@ function buildTarget(positional, extraArgs, opts) {
5268
5304
  ...opts.envFile ? readEnvFile(opts.envFile) : {},
5269
5305
  ...opts.env ?? {}
5270
5306
  };
5307
+ let command = positional;
5308
+ let args = extraArgs;
5309
+ if (extraArgs.length === 0 && /\s/.test(positional)) {
5310
+ const split = splitStdioTarget(positional);
5311
+ command = split.command;
5312
+ args = split.args;
5313
+ }
5271
5314
  return {
5272
5315
  type: "stdio",
5273
- command: positional,
5274
- args: extraArgs,
5316
+ command,
5317
+ args,
5275
5318
  env: Object.keys(env).length ? env : void 0,
5276
5319
  cwd: opts.cwd,
5277
5320
  verbose: opts.verbose
@@ -5286,6 +5329,21 @@ function resolveTarget(cliTarget, cliExtraArgs, cliOpts, config) {
5286
5329
  if (config?.target) return config.target;
5287
5330
  throw new Error("No target specified. Pass a URL or command, or add 'target' to mcp-compliance.config.json.");
5288
5331
  }
5332
+ var PRIVATE_TLD_SUFFIXES = [
5333
+ ".local",
5334
+ ".localhost",
5335
+ ".internal",
5336
+ ".corp",
5337
+ ".home",
5338
+ ".home.arpa",
5339
+ ".lan",
5340
+ ".intranet",
5341
+ ".private",
5342
+ ".test",
5343
+ ".invalid",
5344
+ ".example",
5345
+ ".onion"
5346
+ ];
5289
5347
  function isPrivateHost(urlStr) {
5290
5348
  let host;
5291
5349
  try {
@@ -5293,7 +5351,10 @@ function isPrivateHost(urlStr) {
5293
5351
  } catch {
5294
5352
  return false;
5295
5353
  }
5296
- if (host === "localhost" || host.endsWith(".localhost")) return true;
5354
+ if (host === "localhost") return true;
5355
+ for (const suffix of PRIVATE_TLD_SUFFIXES) {
5356
+ if (host === suffix.slice(1) || host.endsWith(suffix)) return true;
5357
+ }
5297
5358
  const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
5298
5359
  if (v4) {
5299
5360
  const [a, b] = v4.slice(1).map(Number);
@@ -2,7 +2,7 @@ import {
2
2
  SPEC_BASE,
3
3
  TEST_DEFINITIONS,
4
4
  runComplianceSuite
5
- } from "../chunk-BX22BHC5.js";
5
+ } from "../chunk-X5CVUDPW.js";
6
6
 
7
7
  // src/mcp/server.ts
8
8
  import { existsSync, readFileSync, realpathSync } from "fs";
package/dist/runner.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  previewTests,
11
11
  runComplianceSuite,
12
12
  urlHash
13
- } from "./chunk-BX22BHC5.js";
13
+ } from "./chunk-X5CVUDPW.js";
14
14
  export {
15
15
  SPEC_BASE,
16
16
  SPEC_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/mcp-compliance",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "CLI tool and MCP server that tests MCP servers for spec compliance",
5
5
  "license": "MIT",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",