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 +5 -3
- package/dist/cli.js +10 -2
- package/dist/fleet.js +100 -8
- package/dist/junit.js +35 -3
- package/dist/redact.js +1 -1
- package/package.json +1 -1
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
|
-
-
|
|
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
|
|
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
|
|
606
|
-
|
|
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 {
|
|
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({
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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, '&')
|
|
4
17
|
.replace(/</g, '<')
|
|
5
18
|
.replace(/>/g, '>')
|
|
@@ -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="${
|
|
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
|
-
|
|
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
|
}
|