mcp-medic 1.2.6 → 1.2.8

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/dist/cli.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface ParsedArgs {
13
13
  updateSnapshotPath?: string;
14
14
  json: boolean;
15
15
  timeoutMs?: number;
16
+ jobs?: number;
16
17
  showFixes: boolean;
17
18
  verbose: boolean;
18
19
  failOn: 'error' | 'warning';
package/dist/cli.js CHANGED
@@ -113,6 +113,14 @@ export function parseArgs(argv) {
113
113
  }
114
114
  args.protocolVersion = val;
115
115
  }
116
+ else if (arg === '--jobs') {
117
+ const value = argv[++i];
118
+ const parsed = value ? Number(value) : NaN;
119
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
120
+ throw new Error(`--jobs requires a positive safe integer, got: ${value ?? '(none)'}`);
121
+ }
122
+ args.jobs = parsed;
123
+ }
116
124
  else {
117
125
  positional.push(arg);
118
126
  }
@@ -213,6 +221,7 @@ OPTIONS
213
221
  --timeout <ms> Per-server handshake timeout in milliseconds (default: 5000)
214
222
  --protocol-version <v> MCP protocolVersion to request: "auto" (default, latest supported)
215
223
  or an explicit version, e.g. ${SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1]}
224
+ --jobs <n> Maximum config files processed at once in check-all (default: 1)
216
225
  --help, -h Show help
217
226
  --version, -V Print the installed mcp-medic version
218
227
 
@@ -568,6 +577,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
568
577
  const fleetReport = await runFleetChecks(glob, {
569
578
  checks,
570
579
  timeoutMs: args.timeoutMs,
580
+ jobs: args.jobs,
571
581
  verbose: args.verbose,
572
582
  protocolVersion: args.protocolVersion,
573
583
  });
package/dist/fleet.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare function findConfigFiles(globOrPattern: string, rootDir?: string)
37
37
  */
38
38
  export declare function runFleetChecks(globPattern: string, options?: RunOptions & {
39
39
  cwd?: string;
40
+ jobs?: number;
40
41
  }): Promise<FleetReport>;
41
42
  /**
42
43
  * Compares two MCP configurations to identify server and attribute drift.
package/dist/fleet.js CHANGED
@@ -3,7 +3,89 @@ 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
+ async function mapWithConcurrency(items, concurrency, worker) {
8
+ const results = new Array(items.length);
9
+ let nextIndex = 0;
10
+ const workerCount = Math.min(concurrency, items.length);
11
+ await Promise.all(Array.from({ length: workerCount }, async () => {
12
+ while (true) {
13
+ const index = nextIndex++;
14
+ if (index >= items.length)
15
+ return;
16
+ results[index] = await worker(items[index]);
17
+ }
18
+ }));
19
+ return results;
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
+ }
7
89
  function findFilesMatching(dir, rootDir, pattern, results = []) {
8
90
  if (!existsSync(dir))
9
91
  return results;
@@ -72,38 +154,36 @@ export function findConfigFiles(globOrPattern, rootDir = process.cwd()) {
72
154
  export async function runFleetChecks(globPattern, options = {}) {
73
155
  const root = options.cwd ?? process.cwd();
74
156
  const filePaths = findConfigFiles(globPattern, root);
75
- const fileResults = [];
76
- let totalServers = 0;
77
- let totalErrors = 0;
78
- let totalWarnings = 0;
79
- for (const filePath of filePaths) {
157
+ const jobs = options.jobs ?? 1;
158
+ if (!Number.isSafeInteger(jobs) || jobs < 1) {
159
+ throw new TypeError(`jobs must be a positive safe integer, got: ${jobs}`);
160
+ }
161
+ const processed = await mapWithConcurrency(filePaths, jobs, async (filePath) => {
80
162
  try {
81
163
  const rawText = readFileSync(filePath, 'utf-8');
82
164
  const rawJson = JSON.parse(rawText);
83
165
  const { config, errors } = loadConfig(rawJson, filePath);
84
166
  if (errors.length > 0 || !config) {
85
- fileResults.push({
86
- filePath,
87
- error: `Config validation failed: ${errors.join(', ')}`,
88
- });
89
- totalErrors += errors.length;
90
- continue;
167
+ return {
168
+ fileResult: { filePath, error: `Config validation failed: ${errors.join(', ')}` },
169
+ errorCount: errors.length,
170
+ };
91
171
  }
92
172
  const checks = options.checks ?? allChecks;
93
173
  const report = await runChecks(config, { ...options, checks });
94
- fileResults.push({ filePath, report });
95
- totalServers += report.summary.servers;
96
- totalErrors += report.summary.errors;
97
- totalWarnings += report.summary.warnings;
174
+ return { fileResult: { filePath, report }, errorCount: report.summary.errors };
98
175
  }
99
176
  catch (err) {
100
- fileResults.push({
101
- filePath,
102
- error: `Could not process file: ${err instanceof Error ? err.message : String(err)}`,
103
- });
104
- totalErrors += 1;
177
+ return {
178
+ fileResult: { filePath, error: `Could not process file: ${err instanceof Error ? err.message : String(err)}` },
179
+ errorCount: 1,
180
+ };
105
181
  }
106
- }
182
+ });
183
+ const fileResults = processed.map((item) => item.fileResult);
184
+ const totalServers = fileResults.reduce((sum, result) => sum + (result.report?.summary.servers ?? 0), 0);
185
+ const totalErrors = processed.reduce((sum, item) => sum + item.errorCount, 0);
186
+ const totalWarnings = fileResults.reduce((sum, result) => sum + (result.report?.summary.warnings ?? 0), 0);
107
187
  return {
108
188
  totalFiles: filePaths.length,
109
189
  successfulFiles: fileResults.filter((f) => !f.error).length,
@@ -133,20 +213,41 @@ export function diffConfigs(configA, configB) {
133
213
  changes.push({ field: 'transport', from: serverA.transport, to: serverB.transport });
134
214
  }
135
215
  if (serverA.command !== serverB.command) {
136
- changes.push({ field: 'command', from: serverA.command, to: serverB.command });
216
+ changes.push({
217
+ field: 'command',
218
+ from: serverA.command === undefined ? undefined : redactInlineSecrets(serverA.command),
219
+ to: serverB.command === undefined ? undefined : redactInlineSecrets(serverB.command),
220
+ });
137
221
  }
138
- if (JSON.stringify(serverA.args) !== JSON.stringify(serverB.args)) {
139
- changes.push({ field: 'args', from: serverA.args, to: serverB.args });
222
+ if (stableStringify(serverA.args) !== stableStringify(serverB.args)) {
223
+ changes.push({ field: 'args', from: redactArgs(serverA.args), to: redactArgs(serverB.args) });
140
224
  }
141
- if (serverA.url !== serverB.url) {
142
- changes.push({ field: 'url', from: serverA.url, to: serverB.url });
225
+ if (stableStringify(serverA.url) !== stableStringify(serverB.url)) {
226
+ changes.push({ field: 'url', from: redactUrl(serverA.url), to: redactUrl(serverB.url) });
143
227
  }
144
- if (JSON.stringify(serverA.env) !== JSON.stringify(serverB.env)) {
228
+ if (stableStringify(serverA.env) !== stableStringify(serverB.env)) {
145
229
  // Report that env changed and which keys, but never raw values —
146
230
  // env vars routinely carry API keys/tokens and diff output gets
147
231
  // pasted into PRs and CI logs.
148
232
  changes.push({ field: 'env', from: redactRecord(serverA.env), to: redactRecord(serverB.env) });
149
233
  }
234
+ if (stableStringify(serverA.headers) !== stableStringify(serverB.headers)) {
235
+ changes.push({ field: 'headers', from: redactRecord(serverA.headers), to: redactRecord(serverB.headers) });
236
+ }
237
+ if (stableStringify(serverA.tokenRefreshUrl) !== stableStringify(serverB.tokenRefreshUrl)) {
238
+ changes.push({
239
+ field: 'tokenRefreshUrl',
240
+ from: redactUrl(serverA.tokenRefreshUrl),
241
+ to: redactUrl(serverB.tokenRefreshUrl),
242
+ });
243
+ }
244
+ if (stableStringify(serverA.tokenRefreshBody) !== stableStringify(serverB.tokenRefreshBody)) {
245
+ changes.push({
246
+ field: 'tokenRefreshBody',
247
+ from: redactDeep(serverA.tokenRefreshBody),
248
+ to: redactDeep(serverB.tokenRefreshBody),
249
+ });
250
+ }
150
251
  if (changes.length > 0) {
151
252
  entries.push({ serverName: name, kind: 'modified', changes });
152
253
  }
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.6",
3
+ "version": "1.2.8",
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",