mcp-medic 1.2.7 โ†’ 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ This is the whole point (a real handshake, not a schema guess) โ€” but it means
32
32
  - ๐Ÿ” **Auto-Discovery**: Run `mcp-medic check` with no arguments to auto-discover Claude Desktop, `.mcp.json`, and VS Code/Cursor MCP configuration paths across macOS, Windows, and Linux.
33
33
  - ๐Ÿ’ก **Auto-Fix Suggestions**: Diagnose issues with clear, actionable fix suggestions using `--show-fixes`.
34
34
  - ๐ŸŒ **Registry Validation**: Validate published registry entries directly using `mcp-medic check --registry <server-id>`.
35
- - ๐Ÿงช **Fleet Validation** (experimental): Scan and validate monorepos or multi-team configurations with `mcp-medic check-all "<glob>"`.
35
+ - **Fleet Validation**: Scan and validate monorepos or multi-team configurations with deterministic `*`/`**` glob matching, bounded parallel checks, and CI-ready failure handling.
36
36
  - ๐Ÿงช **Drift Detection** (experimental): Catch environment divergence between staging and production configs with `mcp-medic diff <configA> <configB>`.
37
37
  - ๐Ÿ“œ **Policy-as-Code**: Enforce organizational constraints (banned transports, domain allowlists, minimum description lengths, a minimum quality score, a max tool count, required tool descriptions) via `.mcp-medic-policy.json` / `--policy`.
38
38
  - ๐Ÿ“ธ **Snapshot Baseline Mode**: Filter out legacy diagnostics with `--snapshot <baseline.json>` to gate only on newly introduced regressions.
@@ -151,13 +151,16 @@ npx mcp-medic watch path/to/config.json
151
151
  | `--verbose`, `-v` | Output raw JSON-RPC traffic and debug messages |
152
152
  | `--json` | Output full diagnostic report in JSON |
153
153
  | `--timeout <ms>` | Per-server handshake timeout in milliseconds (default: `5000`) |
154
+ | `--jobs <n>` | Maximum configs processed concurrently by `check-all` (positive integer; default: `1`) |
154
155
  | `--help`, `-h` | Show usage help |
155
156
  | `--version`, `-V` | Print the installed version |
156
157
 
158
+ `check-all` interprets globs relative to the current working directory: `*` matches within one path segment, while `**` can cross directories. Results are sorted by path. It skips `node_modules`, `.git`, and `dist`, does not follow symlinked directories, and processes one config at a time unless `--jobs` is set. A zero-match pattern, invalid config, or failed server connection returns exit code `1`; with `--fail-on error`, warnings alone remain non-blocking. In `--json` mode the no-match guidance is written to stderr so stdout stays valid JSON. Like `check`, fleet validation launches the commands and makes the network requests declared by the configs, so only point it at configs you trust.
159
+
157
160
  ### Exit Codes
158
161
 
159
162
  - **`0`**: All checks passed cleanly.
160
- - **`1`**: Diagnostic failure (one or more errors, or warnings if `--fail-on warning` is set).
163
+ - **`1`**: Diagnostic, config, or connection failure; warnings also fail if `--fail-on warning` is set. `check-all` also returns `1` when no files match.
161
164
  - **`2`**: Configuration or usage error (missing file, JSON parse error, invalid options).
162
165
 
163
166
  ---
@@ -303,7 +306,6 @@ The `security.*` checks are heuristic โ€” they pattern-match on what a server *d
303
306
  - **Handshake timeout defaults to 5000ms** per server (`--timeout <ms>` to change it). A slow-starting stdio server or a server behind a slow network path can fail with `status: 'timeout'` even though it would eventually respond.
304
307
  - **`schema.sample-call-simulation`** builds synthetic payloads from a tool's declared JSON Schema and checks the schema is internally consistent (e.g. catches an empty `enum`, or conflicting `minimum`/`maximum`) โ€” it does **not** actually invoke the tool, and it does not validate business logic, side effects, or whether the tool's real output matches its declared schema.
305
308
  - **`security.*` checks are heuristic pattern-matching**, not a security audit โ€” see the note above. They can both miss real issues and flag benign configs (e.g. a legitimate local dev server on plain `http://`).
306
- - **Fleet commands (`check-all`, `diff`) are newer and less battle-tested** than `check`/`watch` โ€” the core check pipeline they're built on is the same, but edge cases in glob matching or drift diffing are more likely.
307
309
  - **The VS Code extension and community check packages are not shipped/published** โ€” see the sections above.
308
310
  - **The Reliability quality dimension is currently binary**: 100 if the server connected, 0 if it didn't (plus any future `reliability.*` diagnostics โ€” none exist yet). Signals like latency trends, retry behavior, or flakiness across repeated runs aren't scored yet.
309
311
  - **`resources/list`/`prompts/list` pagination (`nextCursor`) is not followed** โ€” mcp-medic inspects only the first page a server returns, matching the existing (also unpaginated) `tools/list` handling. A server with a very large resource/prompt catalog behind pagination will be under-inspected.
package/dist/cli.js CHANGED
@@ -581,6 +581,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
581
581
  verbose: args.verbose,
582
582
  protocolVersion: args.protocolVersion,
583
583
  });
584
+ const noMatches = fleetReport.totalFiles === 0;
585
+ if (noMatches) {
586
+ console.error(pc.red(`No configuration files matched "${glob}". Check the glob pattern and run from the intended project root.`));
587
+ }
584
588
  if (args.exportJunit) {
585
589
  try {
586
590
  writeFileSync(resolve(args.exportJunit), formatFleetReportJUnit(fleetReport));
@@ -602,13 +606,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
602
606
  console.log(pc.red(`[FAIL] ${res.filePath} โ€” ${res.error}`));
603
607
  }
604
608
  else if (res.report) {
605
- const status = res.report.summary.errors === 0 ? pc.green('[PASS]') : pc.red('[FAIL]');
606
- console.log(`${status} ${res.filePath} (${res.report.summary.servers} servers, ${res.report.summary.errors} errors, ${res.report.summary.warnings} warnings)`);
609
+ const status = res.report.summary.errors === 0 && res.report.summary.failed === 0
610
+ ? pc.green('[PASS]')
611
+ : pc.red('[FAIL]');
612
+ console.log(`${status} ${res.filePath} (${res.report.summary.servers} servers, ${res.report.summary.failed} connection failure(s), ${res.report.summary.errors} errors, ${res.report.summary.warnings} warnings)`);
607
613
  }
608
614
  }
609
615
  }
610
616
  const hasErrors = fleetReport.totalErrors > 0;
611
617
  const hasWarnings = fleetReport.totalWarnings > 0;
618
+ if (noMatches)
619
+ return 1;
612
620
  if (args.failOn === 'warning') {
613
621
  return hasErrors || hasWarnings ? 1 : 0;
614
622
  }
package/dist/fleet.js CHANGED
@@ -3,7 +3,7 @@ import { resolve, join } from 'node:path';
3
3
  import { loadConfig } from './config-loader.js';
4
4
  import { runChecks } from './orchestrator.js';
5
5
  import { allChecks } from './checks/index.js';
6
- import { redactRecord } from './redact.js';
6
+ import { isSecretKey, redactDeep, redactRecord } from './redact.js';
7
7
  async function mapWithConcurrency(items, concurrency, worker) {
8
8
  const results = new Array(items.length);
9
9
  let nextIndex = 0;
@@ -18,6 +18,74 @@ async function mapWithConcurrency(items, concurrency, worker) {
18
18
  }));
19
19
  return results;
20
20
  }
21
+ function canonicalize(value) {
22
+ if (Array.isArray(value))
23
+ return value.map(canonicalize);
24
+ if (value && typeof value === 'object') {
25
+ return Object.fromEntries(Object.keys(value)
26
+ .sort()
27
+ .map((key) => [key, canonicalize(value[key])]));
28
+ }
29
+ return value;
30
+ }
31
+ function stableStringify(value) {
32
+ return JSON.stringify(canonicalize(value)) ?? 'undefined';
33
+ }
34
+ function redactInlineSecrets(value) {
35
+ return value
36
+ .replace(/(\bBearer\s+)[^\s,;"']+/gi, '$1[REDACTED]')
37
+ .replace(/([a-z0-9_.-]*(?:authorization|token|secret|password|passwd|pwd|api[-_]?key|apikey|cookie|credential|bearer)[a-z0-9_.-]*\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
38
+ }
39
+ function redactArgs(args) {
40
+ if (!args)
41
+ return args;
42
+ const result = [...args];
43
+ for (let i = 0; i < result.length; i += 1) {
44
+ const arg = result[i];
45
+ const equalsIndex = arg.indexOf('=');
46
+ if (equalsIndex >= 0 && isSecretKey(arg.slice(0, equalsIndex).replace(/^-+/, ''))) {
47
+ result[i] = `${arg.slice(0, equalsIndex + 1)}[REDACTED]`;
48
+ }
49
+ else if (isSecretKey(arg.replace(/^-+/, '')) && i + 1 < result.length) {
50
+ result[i + 1] = '[REDACTED]';
51
+ i += 1;
52
+ }
53
+ }
54
+ return result.map(redactInlineSecrets);
55
+ }
56
+ /** Removes URL credentials and secret-looking query/fragment values before a diff is displayed. */
57
+ function redactUrl(value) {
58
+ if (value === undefined)
59
+ return undefined;
60
+ try {
61
+ const url = new URL(value);
62
+ if (url.username)
63
+ url.username = '[REDACTED]';
64
+ if (url.password)
65
+ url.password = '[REDACTED]';
66
+ for (const key of [...url.searchParams.keys()]) {
67
+ if (isSecretKey(key))
68
+ url.searchParams.set(key, '[REDACTED]');
69
+ }
70
+ if (url.hash)
71
+ url.hash = '[REDACTED]';
72
+ return url.toString();
73
+ }
74
+ catch {
75
+ // Invalid URL-like values still get a conservative pass over common credential forms.
76
+ return value
77
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@?#]+@/i, '$1[REDACTED]@')
78
+ .replace(/([?&])([^=&#]+)=([^&#]*)/g, (match, separator, rawKey) => {
79
+ let key = rawKey;
80
+ try {
81
+ key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
82
+ }
83
+ catch { /* retain raw key */ }
84
+ return isSecretKey(key) ? `${separator}${rawKey}=[REDACTED]` : match;
85
+ })
86
+ .replace(/#.*$/, '#[REDACTED]');
87
+ }
88
+ }
21
89
  function findFilesMatching(dir, rootDir, pattern, results = []) {
22
90
  if (!existsSync(dir))
23
91
  return results;
@@ -103,7 +171,10 @@ export async function runFleetChecks(globPattern, options = {}) {
103
171
  }
104
172
  const checks = options.checks ?? allChecks;
105
173
  const report = await runChecks(config, { ...options, checks });
106
- return { fileResult: { filePath, report }, errorCount: report.summary.errors };
174
+ return {
175
+ fileResult: { filePath, report },
176
+ errorCount: report.summary.errors + report.summary.failed,
177
+ };
107
178
  }
108
179
  catch (err) {
109
180
  return {
@@ -145,20 +216,41 @@ export function diffConfigs(configA, configB) {
145
216
  changes.push({ field: 'transport', from: serverA.transport, to: serverB.transport });
146
217
  }
147
218
  if (serverA.command !== serverB.command) {
148
- changes.push({ field: 'command', from: serverA.command, to: serverB.command });
219
+ changes.push({
220
+ field: 'command',
221
+ from: serverA.command === undefined ? undefined : redactInlineSecrets(serverA.command),
222
+ to: serverB.command === undefined ? undefined : redactInlineSecrets(serverB.command),
223
+ });
149
224
  }
150
- if (JSON.stringify(serverA.args) !== JSON.stringify(serverB.args)) {
151
- changes.push({ field: 'args', from: serverA.args, to: serverB.args });
225
+ if (stableStringify(serverA.args) !== stableStringify(serverB.args)) {
226
+ changes.push({ field: 'args', from: redactArgs(serverA.args), to: redactArgs(serverB.args) });
152
227
  }
153
- if (serverA.url !== serverB.url) {
154
- changes.push({ field: 'url', from: serverA.url, to: serverB.url });
228
+ if (stableStringify(serverA.url) !== stableStringify(serverB.url)) {
229
+ changes.push({ field: 'url', from: redactUrl(serverA.url), to: redactUrl(serverB.url) });
155
230
  }
156
- if (JSON.stringify(serverA.env) !== JSON.stringify(serverB.env)) {
231
+ if (stableStringify(serverA.env) !== stableStringify(serverB.env)) {
157
232
  // Report that env changed and which keys, but never raw values โ€”
158
233
  // env vars routinely carry API keys/tokens and diff output gets
159
234
  // pasted into PRs and CI logs.
160
235
  changes.push({ field: 'env', from: redactRecord(serverA.env), to: redactRecord(serverB.env) });
161
236
  }
237
+ if (stableStringify(serverA.headers) !== stableStringify(serverB.headers)) {
238
+ changes.push({ field: 'headers', from: redactRecord(serverA.headers), to: redactRecord(serverB.headers) });
239
+ }
240
+ if (stableStringify(serverA.tokenRefreshUrl) !== stableStringify(serverB.tokenRefreshUrl)) {
241
+ changes.push({
242
+ field: 'tokenRefreshUrl',
243
+ from: redactUrl(serverA.tokenRefreshUrl),
244
+ to: redactUrl(serverB.tokenRefreshUrl),
245
+ });
246
+ }
247
+ if (stableStringify(serverA.tokenRefreshBody) !== stableStringify(serverB.tokenRefreshBody)) {
248
+ changes.push({
249
+ field: 'tokenRefreshBody',
250
+ from: redactDeep(serverA.tokenRefreshBody),
251
+ to: redactDeep(serverB.tokenRefreshBody),
252
+ });
253
+ }
162
254
  if (changes.length > 0) {
163
255
  entries.push({ serverName: name, kind: 'modified', changes });
164
256
  }
package/dist/junit.js CHANGED
@@ -1,5 +1,18 @@
1
1
  function escapeXml(unsafe) {
2
- return unsafe
2
+ let validXml = '';
3
+ for (const character of unsafe) {
4
+ const codePoint = character.codePointAt(0);
5
+ const valid = codePoint === 0x9 ||
6
+ codePoint === 0xa ||
7
+ codePoint === 0xd ||
8
+ (codePoint >= 0x20 && codePoint <= 0xd7ff) ||
9
+ (codePoint >= 0xe000 && codePoint <= 0xfffd) ||
10
+ (codePoint >= 0x10000 && codePoint <= 0x10ffff);
11
+ if (valid) {
12
+ validXml += character;
13
+ }
14
+ }
15
+ return validXml
3
16
  .replace(/&/g, '&amp;')
4
17
  .replace(/</g, '&lt;')
5
18
  .replace(/>/g, '&gt;')
@@ -50,12 +63,28 @@ export function formatReportJUnit(report) {
50
63
  */
51
64
  export function formatFleetReportJUnit(fleetReport) {
52
65
  const lines = [];
66
+ let totalTestCases = 0;
67
+ let totalFailures = 0;
68
+ for (const fileResult of fleetReport.fileResults) {
69
+ if (fileResult.report) {
70
+ totalTestCases += fileResult.report.connections.length + fileResult.report.diagnostics.length;
71
+ totalFailures += fileResult.report.connections.filter((conn) => conn.status !== 'connected').length;
72
+ totalFailures += fileResult.report.diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length;
73
+ }
74
+ else if (fileResult.error) {
75
+ totalTestCases += 1;
76
+ totalFailures += 1;
77
+ }
78
+ }
53
79
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
54
- lines.push(`<testsuites name="mcp-medic-fleet" tests="${fleetReport.totalServers}" failures="${fleetReport.totalErrors}">`);
80
+ lines.push(`<testsuites name="mcp-medic-fleet" tests="${totalTestCases}" failures="${totalFailures}">`);
55
81
  for (const fileResult of fleetReport.fileResults) {
56
82
  if (fileResult.report) {
57
83
  const rep = fileResult.report;
58
- lines.push(` <testsuite name="${escapeXml(fileResult.filePath)}" tests="${rep.summary.servers + rep.diagnostics.length}" failures="${rep.summary.errors}">`);
84
+ const testCases = rep.connections.length + rep.diagnostics.length;
85
+ const failures = rep.connections.filter((conn) => conn.status !== 'connected').length +
86
+ rep.diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length;
87
+ lines.push(` <testsuite name="${escapeXml(fileResult.filePath)}" tests="${testCases}" failures="${failures}">`);
59
88
  for (const conn of rep.connections) {
60
89
  if (conn.status === 'connected') {
61
90
  lines.push(` <testcase classname="${escapeXml(conn.server.name)}" name="connection.handshake" />`);
@@ -73,6 +102,9 @@ export function formatFleetReportJUnit(fleetReport) {
73
102
  lines.push(` <failure message="${escapeXml(d.message)}" type="CheckError">${escapeXml(d.message)}</failure>`);
74
103
  lines.push(' </testcase>');
75
104
  }
105
+ else {
106
+ lines.push(` <testcase classname="${escapeXml(scope)}" name="${escapeXml(d.checkId)}"><system-out>[${d.severity}] ${escapeXml(d.message)}</system-out></testcase>`);
107
+ }
76
108
  }
77
109
  lines.push(' </testsuite>');
78
110
  }
package/dist/redact.js CHANGED
@@ -12,7 +12,7 @@ const REDACTED = '[REDACTED]';
12
12
  /** Matches key names that conventionally carry secret values. Intentionally
13
13
  * broad โ€” false positives (redacting a harmless key) are safe; false
14
14
  * negatives (leaking a secret) are not. */
15
- const SECRET_KEY_PATTERN = /(authorization|auth|token|secret|password|passwd|pwd|api[-_]?key|apikey|cookie|credential|bearer|session|private[-_]?key|client[-_]?secret)/i;
15
+ const SECRET_KEY_PATTERN = /(authorization|auth|token|secret|password|passwd|pwd|api[-_]?key|apikey|access[-_]?key|access[-_]?token|(?:^|[-_])key(?:$|[-_])|cookie|credential|bearer|session|private[-_]?key|client[-_]?secret)/i;
16
16
  export function isSecretKey(key) {
17
17
  return SECRET_KEY_PATTERN.test(key);
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-medic",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",