mcp-medic 1.2.3 → 1.2.4

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
@@ -50,6 +50,8 @@ This is the whole point (a real handshake, not a schema guess) — but it means
50
50
 
51
51
  `mcp-medic` computes a deterministic **MCP Quality Score** (0–100) from the same diagnostics shown in the report — there's no separate, opaque scoring model guessing independently. Every point deducted traces back to one or more real diagnostics, and the same input always produces the same score (no LLM, no randomness, no extra network calls beyond the MCP inspection already performed).
52
52
 
53
+ **What the score means — and doesn't**: it's a measurement of what mcp-medic's passive checks actually found (or looked for), not a certification. "Security 93/100" means *mcp-medic's heuristic security checks found issues worth 7 points* — it is not a security audit, and a 100 is not a guarantee the server is safe. Every report includes this disclaimer and a coverage figure so you can tell what was actually inspected (see below).
54
+
53
55
  ```bash
54
56
  mcp-medic score path/to/config.json
55
57
  # or, alongside the normal report:
@@ -60,15 +62,27 @@ mcp-medic check path/to/config.json --score
60
62
 
61
63
  | Dimension | Weight | What it reflects |
62
64
  |---|---|---|
63
- | Protocol | 25% | Version negotiation compatibility, capability-inspection health (`resources/list`/`prompts/list` succeeding), `serverInfo` presence |
65
+ | Protocol | 25% | Version negotiation compatibility, capability-inspection health (`resources/list`/`prompts/list` succeeding), `serverInfo` presence, version downgrades |
64
66
  | Schema | 20% | `schema.*` diagnostics — malformed/missing input schemas, type mismatches, missing required fields |
65
67
  | Agent usability | 20% | Tool/resource/prompt naming, description quality, output schemas, annotations, tool-surface bloat |
66
68
  | Security | 20% | `security.*` heuristic diagnostics (untrusted remotes, overbroad permissions, prompt-injection-risk patterns) |
67
69
  | Reliability | 15% | Whether the server connects at all (currently binary — see [Known Limitations](#known-limitations)) |
68
70
 
71
+ These weights are a deliberate, documented choice — not arbitrary — and are not changed casually; see `.agent-room/DECISIONS.md` if you're curious why.
72
+
69
73
  ### How deductions work
70
74
 
71
- Each dimension starts at 100. Diagnostics are grouped by `checkId` and severity (`error`/`warning`/`info`), each contributing capped points (errors up to 25/checkId, warnings up to 15/checkId, info up to 5/checkId) — so **one noisy check can never dominate a dimension**: 20 tools sharing the same description problem cost at most 15 points, not 300. The report's "Deductions" list names exactly which checks cost how many points.
75
+ Each dimension starts at 100. Diagnostics are grouped by `checkId` and severity (`error`/`warning`/`info`), each contributing capped points (errors up to 25/checkId, warnings up to 15/checkId, info up to 5/checkId) — so **one noisy check can never dominate a dimension**: 20 tools sharing the same description problem cost at most 15 points, not 300. The report's "Deductions" list names exactly which checks cost how many points. **Nothing is deducted that isn't also a visible diagnostic** — including protocol-level facts like a negotiated version downgrade or a capability that failed to list, which are their own `protocol.*` diagnostics, not a hidden number baked into the score.
76
+
77
+ ### Coverage: what was actually inspected
78
+
79
+ A dimension's score only means something if the checks that produce it actually ran. If you run `mcp-medic` with a custom, restricted check set (`runChecks({ checks: [...] })` via the library API), the score's `coverage` tells you which dimensions were fully evaluated (`'covered'`), partially evaluated (`'partial'` — some but not all of that dimension's checks ran), or not evaluated at all (`'not-covered'`). A `security: 'not-covered'` next to `Security 100/100` means "nothing was looked for," not "nothing is wrong." `coveragePercent` summarizes all five as one number. The standard CLI (`check`/`score`) always runs the full built-in check set, so coverage is 100% there by default.
80
+
81
+ A server that fails to connect is never silently averaged out of a fleet's score either — it's named in `quality.unscoredServers`, and the human report calls it out explicitly.
82
+
83
+ ### Protocol-version-aware quality rules
84
+
85
+ Some things a check might flag could be a genuine protocol violation for a given negotiated MCP version, or merely an ecosystem style recommendation — mcp-medic never mislabels one as the other. `src/protocol/quality-rules.ts` centralizes what each supported version actually requires (today: no version defines a hard tool-name length or character-pattern constraint, so this distinction is currently latent — the abstraction exists so a future version that *does* add one only needs a new entry there, not a rewrite of every check).
72
86
 
73
87
  ### Diagnostic categories
74
88
 
@@ -187,6 +201,8 @@ Define organization-wide policies that compose with built-in checks:
187
201
 
188
202
  `quality.minimumScore` fails the run (adds an error diagnostic) if the computed MCP Quality Score falls below the threshold. `quality.maxTools` is an org-enforced hard limit — distinct from the built-in `quality.tool-surface` check's default 100-tool *warning*, which stays a recommendation. `quality.requireToolDescriptions` (or the equivalent top-level `requireToolDescriptions`) turns every missing tool description into a policy error rather than the default warning.
189
203
 
204
+ A `minimumScore` gate is also coverage-aware: if the score was computed from incomplete coverage (or a server that couldn't be scored), a `policy.partial-coverage-with-minimum-score` **warning** is added alongside it — a passing score should never look like a clean bill of health when only part of the server was actually evaluated. This never changes the pass/fail outcome of the `minimumScore` check itself (that stays a plain score-vs-threshold comparison), it just makes a partial assessment visible.
205
+
190
206
  ---
191
207
 
192
208
  ## VS Code Extension (experimental)
@@ -292,6 +308,8 @@ The `security.*` checks are heuristic — they pattern-match on what a server *d
292
308
  - **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.
293
309
  - **`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.
294
310
  - **The quality score never calls `resources/read`, `prompts/get`, or any tool** — it's entirely derived from the passive `initialize`/`tools/list`/`resources/list`/`prompts/list` responses already gathered during a normal `check`. See [SECURITY.md](./SECURITY.md) for the full passive-only guarantee.
311
+ - **The CLI cannot currently restrict the check set** (no `--only-checks` flag), so `coverage` is always 100% via `check`/`score`. Partial coverage (and the coverage-aware `minimumScore` warning) only happens when the library API's `runChecks({ checks: [...] })` is called with a restricted list.
312
+ - **No currently-supported MCP protocol version defines a hard tool-name length or character-pattern constraint**, so `src/protocol/quality-rules.ts`'s protocol-vs-quality distinction for tool names is real but currently dormant — every finding today is a quality recommendation, never a protocol violation, because no version actually requires one. The abstraction is there for when a future version does.
295
313
  - **npm README sync**: Latest docs live on GitHub main; npm README updates on the next publish.
296
314
  - **First run via `npx`** pays a one-time cost to resolve and download the package; once installed (or on a warm npx cache), `--help`/`--version` return in well under 100ms.
297
315
 
@@ -13,4 +13,5 @@ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
13
  export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
14
  export { qualityResourcesCheck } from './quality-resources.js';
15
15
  export { qualityPromptsCheck } from './quality-prompts.js';
16
+ export { protocolConnectionHealthCheck } from './protocol-connection-health.js';
16
17
  export declare const allChecks: import("../types.js").Check[];
@@ -13,6 +13,7 @@ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
13
  export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
14
  export { qualityResourcesCheck } from './quality-resources.js';
15
15
  export { qualityPromptsCheck } from './quality-prompts.js';
16
+ export { protocolConnectionHealthCheck } from './protocol-connection-health.js';
16
17
  import { malformedSchemaCheck } from './malformed-schema.js';
17
18
  import { missingRequiredFieldsCheck } from './missing-required-fields.js';
18
19
  import { typeMismatchCheck } from './type-mismatch.js';
@@ -28,7 +29,9 @@ import { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
28
29
  import { qualityToolSurfaceCheck } from './quality-tool-surface.js';
29
30
  import { qualityResourcesCheck } from './quality-resources.js';
30
31
  import { qualityPromptsCheck } from './quality-prompts.js';
32
+ import { protocolConnectionHealthCheck } from './protocol-connection-health.js';
31
33
  export const allChecks = [
34
+ protocolConnectionHealthCheck,
32
35
  malformedSchemaCheck,
33
36
  missingRequiredFieldsCheck,
34
37
  typeMismatchCheck,
@@ -0,0 +1,17 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * Surfaces connection-level protocol facts as real, visible diagnostics —
4
+ * previously these (capability-inspection failures, a version downgrade, a
5
+ * missing serverInfo) were deducted from the quality score directly from
6
+ * `MCPConnection` metadata inside src/quality-score.ts, with no
7
+ * corresponding diagnostic a user could actually see in the report or
8
+ * `--json` output. That meant a developer looking at "why did my protocol
9
+ * score drop" would find nothing in `diagnostics` explaining it.
10
+ *
11
+ * This check makes those same facts flow through the standard
12
+ * connection -> diagnostics -> dimension -> score pipeline like every
13
+ * other check, so every point deducted is explainable from a visible
14
+ * DiagnosticResult (see .agent-room/DECISIONS.md for the full rationale,
15
+ * including why this also changes some of the exact point values).
16
+ */
17
+ export declare const protocolConnectionHealthCheck: Check;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Surfaces connection-level protocol facts as real, visible diagnostics —
3
+ * previously these (capability-inspection failures, a version downgrade, a
4
+ * missing serverInfo) were deducted from the quality score directly from
5
+ * `MCPConnection` metadata inside src/quality-score.ts, with no
6
+ * corresponding diagnostic a user could actually see in the report or
7
+ * `--json` output. That meant a developer looking at "why did my protocol
8
+ * score drop" would find nothing in `diagnostics` explaining it.
9
+ *
10
+ * This check makes those same facts flow through the standard
11
+ * connection -> diagnostics -> dimension -> score pipeline like every
12
+ * other check, so every point deducted is explainable from a visible
13
+ * DiagnosticResult (see .agent-room/DECISIONS.md for the full rationale,
14
+ * including why this also changes some of the exact point values).
15
+ */
16
+ export const protocolConnectionHealthCheck = {
17
+ id: 'protocol.connection-health',
18
+ description: 'Flags protocol-level connection facts: a negotiated version downgrade, a missing serverInfo, or a declared capability whose list call failed.',
19
+ run(connection) {
20
+ const results = [];
21
+ try {
22
+ if (connection.protocolVersion?.negotiated &&
23
+ connection.protocolVersion.negotiated !== connection.protocolVersion.requested) {
24
+ results.push({
25
+ checkId: 'protocol.version-downgrade',
26
+ severity: 'info',
27
+ message: `Server negotiated protocol version ${connection.protocolVersion.negotiated} instead of the requested ${connection.protocolVersion.requested}.`,
28
+ serverName: connection.server.name,
29
+ category: 'protocol',
30
+ details: {
31
+ requested: connection.protocolVersion.requested,
32
+ negotiated: connection.protocolVersion.negotiated,
33
+ },
34
+ });
35
+ }
36
+ if (!connection.serverInfo?.name) {
37
+ results.push({
38
+ checkId: 'protocol.missing-server-info',
39
+ severity: 'info',
40
+ message: 'Server did not report its name/version in serverInfo during initialize.',
41
+ serverName: connection.server.name,
42
+ category: 'protocol',
43
+ suggestedFix: { description: 'Have the server include a serverInfo.name in its initialize response.' },
44
+ });
45
+ }
46
+ if (connection.capabilityErrors?.resources) {
47
+ results.push({
48
+ checkId: 'protocol.capability-error',
49
+ severity: 'error',
50
+ message: `Server declared the "resources" capability, but resources/list failed: ${connection.capabilityErrors.resources}`,
51
+ serverName: connection.server.name,
52
+ category: 'protocol',
53
+ details: { capability: 'resources', error: connection.capabilityErrors.resources },
54
+ });
55
+ }
56
+ if (connection.capabilityErrors?.prompts) {
57
+ results.push({
58
+ checkId: 'protocol.capability-error',
59
+ severity: 'error',
60
+ message: `Server declared the "prompts" capability, but prompts/list failed: ${connection.capabilityErrors.prompts}`,
61
+ serverName: connection.server.name,
62
+ category: 'protocol',
63
+ details: { capability: 'prompts', error: connection.capabilityErrors.prompts },
64
+ });
65
+ }
66
+ }
67
+ catch (err) {
68
+ results.push({
69
+ checkId: 'protocol.connection-health',
70
+ severity: 'error',
71
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
72
+ serverName: connection.server.name,
73
+ category: 'protocol',
74
+ });
75
+ }
76
+ return results;
77
+ },
78
+ };
@@ -1,2 +1,35 @@
1
1
  import type { Check } from '../types.js';
2
+ import { type ToolNameProtocolRules } from '../protocol/quality-rules.js';
3
+ export interface ToolNameFinding {
4
+ severity: 'error' | 'warning';
5
+ /** 'protocol' only when `rules` itself defines a hard constraint the name
6
+ * violates; everything else is 'quality' (a recommendation, never a
7
+ * protocol violation) — see module doc. */
8
+ category: 'protocol' | 'quality';
9
+ message: string;
10
+ confidence?: 'low' | 'medium' | 'high';
11
+ suggestedFixDescription: string;
12
+ }
13
+ /**
14
+ * Pure evaluation of a single tool name, independent of the rest of the
15
+ * connection (duplicate detection is connection-wide and stays in `run()`).
16
+ * Exported so tests can inject a synthetic `ToolNameProtocolRules` (e.g. a
17
+ * hypothetical future protocol version with a real length/pattern
18
+ * constraint) without needing that version to actually exist yet — see
19
+ * src/protocol/quality-rules.ts.
20
+ *
21
+ * The A/B/C distinction this check makes:
22
+ * A. PROTOCOL VIOLATION — only possible if `rules.maxLength`/`rules.pattern`
23
+ * is defined by the negotiated version AND the name violates it.
24
+ * Reported as category 'protocol', severity 'error'.
25
+ * B. QUALITY WARNING — technically spec-valid, but likely to hurt agent
26
+ * usability (too long by convention, odd characters, ambiguous name).
27
+ * Reported as category 'quality', severity 'warning'.
28
+ * C. Empty name is kept as a 'quality' error (not 'protocol') — no
29
+ * supported version's spec actually forbids an empty string, but a
30
+ * tool a client can't reference or distinguish is functionally
31
+ * broken, which still deserves error severity without mislabeling it
32
+ * a protocol violation.
33
+ */
34
+ export declare function evaluateToolName(name: string, rules: ToolNameProtocolRules): ToolNameFinding[];
2
35
  export declare const qualityToolNamesCheck: Check;
@@ -1,9 +1,6 @@
1
- /** MCP's `Tool.name` (from `BaseMetadata`) has no documented pattern or
2
- * length constraint in the specso none of these are "protocol
3
- * violations" in the strict sense. Empty and duplicate names are still
4
- * treated as errors here because a tool a client cannot reference or
5
- * distinguish is functionally broken, not merely unstylish; everything
6
- * else is a warning. */
1
+ import { getProtocolQualityRules } from '../protocol/quality-rules.js';
2
+ /** Ecosystem recommendation, not a protocol constraintsee the module-level
3
+ * note on `evaluateToolName` for the A/B/C distinction this check makes. */
7
4
  const MAX_REASONABLE_NAME_LENGTH = 128;
8
5
  /** Conservative, curated list — exact (case-insensitive) matches only, to
9
6
  * avoid false-positiving on legitimately short/plain real tool names. */
@@ -31,64 +28,106 @@ function hasInvalidCharacters(name) {
31
28
  // eslint-disable-next-line no-control-regex
32
29
  return /[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f]/.test(name);
33
30
  }
31
+ /**
32
+ * Pure evaluation of a single tool name, independent of the rest of the
33
+ * connection (duplicate detection is connection-wide and stays in `run()`).
34
+ * Exported so tests can inject a synthetic `ToolNameProtocolRules` (e.g. a
35
+ * hypothetical future protocol version with a real length/pattern
36
+ * constraint) without needing that version to actually exist yet — see
37
+ * src/protocol/quality-rules.ts.
38
+ *
39
+ * The A/B/C distinction this check makes:
40
+ * A. PROTOCOL VIOLATION — only possible if `rules.maxLength`/`rules.pattern`
41
+ * is defined by the negotiated version AND the name violates it.
42
+ * Reported as category 'protocol', severity 'error'.
43
+ * B. QUALITY WARNING — technically spec-valid, but likely to hurt agent
44
+ * usability (too long by convention, odd characters, ambiguous name).
45
+ * Reported as category 'quality', severity 'warning'.
46
+ * C. Empty name is kept as a 'quality' error (not 'protocol') — no
47
+ * supported version's spec actually forbids an empty string, but a
48
+ * tool a client can't reference or distinguish is functionally
49
+ * broken, which still deserves error severity without mislabeling it
50
+ * a protocol violation.
51
+ */
52
+ export function evaluateToolName(name, rules) {
53
+ const findings = [];
54
+ if (name.trim() === '') {
55
+ findings.push({
56
+ severity: 'error',
57
+ category: 'quality',
58
+ message: 'Tool has an empty name — a client cannot reference or distinguish it.',
59
+ suggestedFixDescription: 'Give the tool a non-empty, descriptive name.',
60
+ });
61
+ return findings; // nothing else meaningful to evaluate on an empty name
62
+ }
63
+ if (rules.maxLength !== undefined && name.length > rules.maxLength) {
64
+ findings.push({
65
+ severity: 'error',
66
+ category: 'protocol',
67
+ message: `Tool name "${name.slice(0, 40)}..." is ${name.length} characters, exceeding the negotiated protocol's maximum of ${rules.maxLength} — this is a protocol violation, not a style recommendation.`,
68
+ suggestedFixDescription: `Shorten the tool name to ${rules.maxLength} characters or fewer to comply with the negotiated protocol version.`,
69
+ });
70
+ }
71
+ else if (name.length > MAX_REASONABLE_NAME_LENGTH) {
72
+ findings.push({
73
+ severity: 'warning',
74
+ category: 'quality',
75
+ confidence: 'medium',
76
+ message: `Tool name "${name.slice(0, 40)}..." name is ${name.length} characters, exceeding the recommended ${MAX_REASONABLE_NAME_LENGTH}.`,
77
+ suggestedFixDescription: 'Shorten the tool name to something concise and memorable.',
78
+ });
79
+ }
80
+ if (rules.pattern && !rules.pattern.test(name)) {
81
+ findings.push({
82
+ severity: 'error',
83
+ category: 'protocol',
84
+ message: `Tool name "${name}" does not match the naming pattern required by the negotiated protocol version — this is a protocol violation, not a style recommendation.`,
85
+ suggestedFixDescription: 'Rename the tool to match the naming pattern required by the negotiated protocol version.',
86
+ });
87
+ }
88
+ else if (hasInvalidCharacters(name)) {
89
+ findings.push({
90
+ severity: 'warning',
91
+ category: 'quality',
92
+ message: `Tool name "${JSON.stringify(name)}" contains whitespace/control characters that may break client tooling.`,
93
+ suggestedFixDescription: 'Use only plain, printable characters in tool names (letters, digits, -, _).',
94
+ });
95
+ }
96
+ if (name.length === 1 || PLACEHOLDER_NAMES.has(name.trim().toLowerCase())) {
97
+ findings.push({
98
+ severity: 'warning',
99
+ category: 'quality',
100
+ confidence: 'medium',
101
+ message: `Tool name "${name}" is ambiguous or looks like a placeholder — it doesn't communicate what the tool does.`,
102
+ suggestedFixDescription: 'Rename the tool to describe its action, e.g. "search_flights" instead of "tool".',
103
+ });
104
+ }
105
+ return findings;
106
+ }
34
107
  export const qualityToolNamesCheck = {
35
108
  id: 'quality.tool-name',
36
- description: 'Flags empty, duplicate, overly long, or placeholder-looking tool names.',
109
+ description: 'Flags empty, duplicate, overly long, or placeholder-looking tool names; distinguishes protocol-version-defined violations from quality recommendations.',
37
110
  run(connection) {
38
111
  const results = [];
39
112
  try {
40
113
  if (!connection.tools || !Array.isArray(connection.tools)) {
41
114
  return results;
42
115
  }
116
+ const rules = getProtocolQualityRules(connection.protocolVersion?.negotiated).toolName;
43
117
  const seen = new Map();
44
118
  for (const tool of connection.tools) {
45
119
  const name = tool.name;
46
120
  seen.set(name, (seen.get(name) ?? 0) + 1);
47
- if (name.trim() === '') {
48
- results.push({
49
- checkId: 'quality.tool-name',
50
- severity: 'error',
51
- message: 'Tool has an empty name — a client cannot reference or distinguish it.',
52
- serverName: connection.server.name,
53
- toolName: name,
54
- category: 'quality',
55
- suggestedFix: { description: 'Give the tool a non-empty, descriptive name.' },
56
- });
57
- continue;
58
- }
59
- if (name.length > MAX_REASONABLE_NAME_LENGTH) {
60
- results.push({
61
- checkId: 'quality.tool-name',
62
- severity: 'warning',
63
- message: `Tool "${name.slice(0, 40)}..." name is ${name.length} characters, exceeding the recommended ${MAX_REASONABLE_NAME_LENGTH}.`,
64
- serverName: connection.server.name,
65
- toolName: name,
66
- category: 'quality',
67
- confidence: 'medium',
68
- suggestedFix: { description: 'Shorten the tool name to something concise and memorable.' },
69
- });
70
- }
71
- if (hasInvalidCharacters(name)) {
72
- results.push({
73
- checkId: 'quality.tool-name',
74
- severity: 'warning',
75
- message: `Tool name "${JSON.stringify(name)}" contains whitespace/control characters that may break client tooling.`,
76
- serverName: connection.server.name,
77
- toolName: name,
78
- category: 'quality',
79
- suggestedFix: { description: 'Use only plain, printable characters in tool names (letters, digits, -, _).' },
80
- });
81
- }
82
- if (name.length === 1 || PLACEHOLDER_NAMES.has(name.trim().toLowerCase())) {
121
+ for (const finding of evaluateToolName(name, rules)) {
83
122
  results.push({
84
123
  checkId: 'quality.tool-name',
85
- severity: 'warning',
86
- message: `Tool name "${name}" is ambiguous or looks like a placeholder — it doesn't communicate what the tool does.`,
124
+ severity: finding.severity,
125
+ message: finding.message,
87
126
  serverName: connection.server.name,
88
127
  toolName: name,
89
- category: 'quality',
90
- confidence: 'medium',
91
- suggestedFix: { description: 'Rename the tool to describe its action, e.g. "search_flights" instead of "tool".' },
128
+ category: finding.category,
129
+ ...(finding.confidence ? { confidence: finding.confidence } : {}),
130
+ suggestedFix: { description: finding.suggestedFixDescription },
92
131
  });
93
132
  }
94
133
  }
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { loadPolicy, createPolicyChecks } from './policy.js';
14
14
  import { runFleetChecks, diffConfigs, filterDiagnosticsByBaseline } from './fleet.js';
15
15
  import { formatReportJUnit, formatFleetReportJUnit } from './junit.js';
16
16
  import { formatReportSarif } from './sarif.js';
17
- import { computeReportQualityScore } from './quality-score.js';
17
+ import { computeReportQualityScore, checkMinimumScorePolicy } from './quality-score.js';
18
18
  import { SUPPORTED_PROTOCOL_VERSIONS } from './protocol/versions.js';
19
19
  import pc from 'picocolors';
20
20
  export function parseArgs(argv) {
@@ -286,7 +286,7 @@ async function executeCheck(config, args) {
286
286
  // The quality score must reflect what's actually being reported —
287
287
  // recompute it against the post-baseline-filter diagnostics rather
288
288
  // than leaving the pre-filter score (computed by runChecks) stale.
289
- report.quality = computeReportQualityScore(report);
289
+ report.quality = computeReportQualityScore(report, checks);
290
290
  }
291
291
  catch (err) {
292
292
  console.error(pc.yellow(`Warning: Could not read snapshot baseline: ${String(err)}`));
@@ -298,16 +298,11 @@ async function executeCheck(config, args) {
298
298
  // check's output, not a single connection), so it's enforced here instead.
299
299
  const policy = loadPolicy(args.policyPath);
300
300
  if (typeof policy?.quality?.minimumScore === 'number' && report.quality) {
301
- if (report.quality.overall < policy.quality.minimumScore) {
302
- report.diagnostics.push({
303
- checkId: 'policy.minimum-quality-score',
304
- severity: 'error',
305
- message: `MCP quality score ${report.quality.overall} is below the policy minimum of ${policy.quality.minimumScore}.`,
306
- serverName: config.servers.map((s) => s.name).join(', ') || '(no servers)',
307
- category: 'configuration',
308
- });
309
- report.summary.errors += 1;
310
- }
301
+ const allServerNames = config.servers.map((s) => s.name).join(', ') || '(no servers)';
302
+ const policyDiagnostics = checkMinimumScorePolicy(report.quality, policy.quality.minimumScore, allServerNames);
303
+ report.diagnostics.push(...policyDiagnostics);
304
+ report.summary.errors += policyDiagnostics.filter((d) => d.severity === 'error').length;
305
+ report.summary.warnings += policyDiagnostics.filter((d) => d.severity === 'warning').length;
311
306
  }
312
307
  // Handle update snapshot
313
308
  if (args.updateSnapshotPath) {
package/dist/index.d.ts CHANGED
@@ -22,5 +22,10 @@ export { SUPPORTED_PROTOCOL_VERSIONS, LATEST_SUPPORTED_PROTOCOL_VERSION, KNOWN_U
22
22
  export type { SupportedProtocolVersion, ProtocolVersionNegotiation } from './protocol/versions.js';
23
23
  export { isSecretKey, redactRecord, redactDeep, sanitizeServerConfig } from './redact.js';
24
24
  export { inferDiagnosticCategory, categoryOf } from './diagnostics.js';
25
- export { computeConnectionQualityScore, computeReportQualityScore, QUALITY_DIMENSIONS, QUALITY_DIMENSION_WEIGHTS, } from './quality-score.js';
25
+ export { computeConnectionQualityScore, computeReportQualityScore, computeQualityCoverage, checkMinimumScorePolicy, QUALITY_DIMENSIONS, QUALITY_DIMENSION_WEIGHTS, QUALITY_SCORE_DISCLAIMER, } from './quality-score.js';
26
+ export { getProtocolQualityRules } from './protocol/quality-rules.js';
27
+ export type { ProtocolQualityRules, ToolNameProtocolRules } from './protocol/quality-rules.js';
28
+ export { protocolConnectionHealthCheck } from './checks/protocol-connection-health.js';
29
+ export { evaluateToolName } from './checks/quality-tool-names.js';
30
+ export type { ToolNameFinding } from './checks/quality-tool-names.js';
26
31
  export * from './types.js';
package/dist/index.js CHANGED
@@ -14,5 +14,8 @@ export { allChecks } from './checks/index.js';
14
14
  export { SUPPORTED_PROTOCOL_VERSIONS, LATEST_SUPPORTED_PROTOCOL_VERSION, KNOWN_UNSUPPORTED_PROTOCOL_VERSIONS, isSupportedProtocolVersion, resolveRequestedProtocolVersion, } from './protocol/versions.js';
15
15
  export { isSecretKey, redactRecord, redactDeep, sanitizeServerConfig } from './redact.js';
16
16
  export { inferDiagnosticCategory, categoryOf } from './diagnostics.js';
17
- export { computeConnectionQualityScore, computeReportQualityScore, QUALITY_DIMENSIONS, QUALITY_DIMENSION_WEIGHTS, } from './quality-score.js';
17
+ export { computeConnectionQualityScore, computeReportQualityScore, computeQualityCoverage, checkMinimumScorePolicy, QUALITY_DIMENSIONS, QUALITY_DIMENSION_WEIGHTS, QUALITY_SCORE_DISCLAIMER, } from './quality-score.js';
18
+ export { getProtocolQualityRules } from './protocol/quality-rules.js';
19
+ export { protocolConnectionHealthCheck } from './checks/protocol-connection-health.js';
20
+ export { evaluateToolName } from './checks/quality-tool-names.js';
18
21
  export * from './types.js';
@@ -64,7 +64,7 @@ export async function runChecks(config, options = {}) {
64
64
  diagnostics,
65
65
  summary,
66
66
  };
67
- const quality = computeReportQualityScore(report);
67
+ const quality = computeReportQualityScore(report, checks);
68
68
  if (quality) {
69
69
  report.quality = quality;
70
70
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Protocol-version-aware quality rules.
3
+ *
4
+ * Some things a quality check might flag are genuine protocol violations
5
+ * for a given negotiated MCP version (hard requirement/prohibition); others
6
+ * are ecosystem recommendations that happen to be entirely valid per spec.
7
+ * Mixing these up means a check can wrongly call a style preference a
8
+ * "protocol violation," or (worse) miss a real one because the version
9
+ * that defines it was never consulted.
10
+ *
11
+ * This module is the single place that answers "does this negotiated
12
+ * version define a hard constraint here?" so quality checks don't each
13
+ * have to know MCP spec history — and so a future protocol version that
14
+ * *does* add a constraint only needs a new entry here, not a rewrite of
15
+ * every check that uses it.
16
+ *
17
+ * Current state of research (see src/protocol/versions.ts for the same
18
+ * source): every version in SUPPORTED_PROTOCOL_VERSIONS (2024-11-05
19
+ * through 2025-11-25) defines `Tool.name` as a plain `string` with no
20
+ * documented length or character-pattern constraint. So today, every
21
+ * version returns the same (empty) rule set — this module exists for the
22
+ * abstraction, not because any current version actually differs from
23
+ * another.
24
+ */
25
+ export interface ToolNameProtocolRules {
26
+ /** A hard maximum length the negotiated version's spec actually defines
27
+ * for `Tool.name`, if any. Exceeding it is a protocol violation, not a
28
+ * style recommendation. `undefined` = the spec defines no such limit for
29
+ * this version — length-based feedback should be a quality warning, not
30
+ * an error. */
31
+ maxLength?: number;
32
+ /** A hard character-pattern the negotiated version's spec actually
33
+ * requires `Tool.name` to match, if any. `undefined` = no such
34
+ * constraint — character-based feedback should be a quality warning. */
35
+ pattern?: RegExp;
36
+ }
37
+ export interface ProtocolQualityRules {
38
+ toolName: ToolNameProtocolRules;
39
+ }
40
+ /**
41
+ * Returns the protocol-defined (hard) quality rules for a negotiated MCP
42
+ * version. `version` is normally `MCPConnection.protocolVersion.negotiated`.
43
+ * An unknown or absent version gets the same "no hard constraints" rules
44
+ * as every currently-supported version, rather than guessing.
45
+ */
46
+ export declare function getProtocolQualityRules(version: string | undefined): ProtocolQualityRules;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Protocol-version-aware quality rules.
3
+ *
4
+ * Some things a quality check might flag are genuine protocol violations
5
+ * for a given negotiated MCP version (hard requirement/prohibition); others
6
+ * are ecosystem recommendations that happen to be entirely valid per spec.
7
+ * Mixing these up means a check can wrongly call a style preference a
8
+ * "protocol violation," or (worse) miss a real one because the version
9
+ * that defines it was never consulted.
10
+ *
11
+ * This module is the single place that answers "does this negotiated
12
+ * version define a hard constraint here?" so quality checks don't each
13
+ * have to know MCP spec history — and so a future protocol version that
14
+ * *does* add a constraint only needs a new entry here, not a rewrite of
15
+ * every check that uses it.
16
+ *
17
+ * Current state of research (see src/protocol/versions.ts for the same
18
+ * source): every version in SUPPORTED_PROTOCOL_VERSIONS (2024-11-05
19
+ * through 2025-11-25) defines `Tool.name` as a plain `string` with no
20
+ * documented length or character-pattern constraint. So today, every
21
+ * version returns the same (empty) rule set — this module exists for the
22
+ * abstraction, not because any current version actually differs from
23
+ * another.
24
+ */
25
+ /** No currently-supported MCP version defines a hard length/pattern
26
+ * constraint on Tool.name — see the module doc comment. */
27
+ const NO_HARD_CONSTRAINTS = { toolName: {} };
28
+ /**
29
+ * Returns the protocol-defined (hard) quality rules for a negotiated MCP
30
+ * version. `version` is normally `MCPConnection.protocolVersion.negotiated`.
31
+ * An unknown or absent version gets the same "no hard constraints" rules
32
+ * as every currently-supported version, rather than guessing.
33
+ */
34
+ export function getProtocolQualityRules(version) {
35
+ void version; // reserved for when a supported version actually defines a constraint
36
+ return NO_HARD_CONSTRAINTS;
37
+ }
@@ -1,33 +1,76 @@
1
- import type { MCPConnection, DiagnosticResult, RunReport, QualityDimension, QualityDeduction, QualityScoreBreakdown, ReportQualityScore } from './types.js';
1
+ import type { MCPConnection, DiagnosticResult, RunReport, QualityDimension, QualityDeduction, QualityScoreBreakdown, ReportQualityScore, QualityCoverage, Check } from './types.js';
2
2
  /**
3
3
  * Deterministic MCP quality score. No LLM, no randomness, no network
4
4
  * calls beyond the MCP inspection `connect()` already performed — the same
5
5
  * report always produces the same score.
6
6
  *
7
- * Design: diagnostics -> normalized per-checkId deductions -> capped
8
- * per-dimension score -> weighted overall score. This keeps the score
9
- * fully derived from (and consistent with) the diagnostics that are also
10
- * shown in the report, rather than an independent "second opinion" — see
11
- * .agent-room/DECISIONS.md for the full rationale.
7
+ * Design: connection -> diagnostics -> normalized per-checkId deductions ->
8
+ * capped per-dimension score -> weighted overall score. Every deduction is
9
+ * derived from a visible `DiagnosticResult` nothing is deducted directly
10
+ * from connection metadata that isn't also represented as a diagnostic
11
+ * (see .agent-room/DECISIONS.md, "v1.1 trust hardening" entry, for the
12
+ * `protocol.connection-health` check this replaced).
12
13
  *
13
14
  * (Result types — QualityDimension, QualityDeduction, QualityScoreBreakdown,
14
- * ReportQualityScore — live in src/types.ts alongside RunReport, since
15
- * RunReport.quality is one of them; this module just implements the math.)
15
+ * ReportQualityScore, QualityCoverage, CoverageStatus — live in src/types.ts
16
+ * alongside RunReport, since RunReport.quality is one of them; this module
17
+ * just implements the math.)
16
18
  */
17
19
  export type { QualityDimension, QualityDeduction, QualityScoreBreakdown, ReportQualityScore };
18
20
  export declare const QUALITY_DIMENSIONS: readonly QualityDimension[];
19
21
  /** Suggested weights from the product spec; documented here since every
20
- * point deduction must be explainable in terms of these. */
22
+ * point deduction must be explainable in terms of these. Do not change
23
+ * without a documented reason — see .agent-room/DECISIONS.md. */
21
24
  export declare const QUALITY_DIMENSION_WEIGHTS: Record<QualityDimension, number>;
22
25
  /**
23
- * Computes a quality score for one connection. Returns `undefined` for a
24
- * connection that never connected there's nothing meaningful to score
25
- * (no tools/schema/capabilities were ever observed).
26
+ * The score is a measurement, not a certification: it reports what
27
+ * mcp-medic's passive checks actually found (or, per `coverage`, actually
28
+ * looked for) never a formal audit of the server's real security or
29
+ * behavior. Surfaced in both JSON (`ReportQualityScore.disclaimer`) and the
30
+ * human report.
31
+ */
32
+ export declare const QUALITY_SCORE_DISCLAIMER: string;
33
+ /**
34
+ * Computes a quality score for one connection, purely from `diagnostics`
35
+ * (filtered to that server) — no connection metadata is consulted
36
+ * directly. Returns `undefined` for a connection that never connected —
37
+ * there's nothing meaningful to score (no tools/schema/capabilities were
38
+ * ever observed); see `computeReportQualityScore` for how the report-level
39
+ * score surfaces that instead of silently dropping it.
26
40
  */
27
41
  export declare function computeConnectionQualityScore(connection: MCPConnection, diagnostics: DiagnosticResult[]): QualityScoreBreakdown | undefined;
42
+ /**
43
+ * Coverage answers "was this dimension actually evaluated," independent of
44
+ * what score it got — a 100 from zero checks run means "nothing was
45
+ * looked for," not "nothing is wrong." Derived from the *ids* of the
46
+ * checks that actually ran (`executedChecks`), compared against the
47
+ * built-in `allChecks` reference set grouped by dimension. Reliability is
48
+ * special-cased to always 'covered': its signal (did the connection
49
+ * succeed) isn't produced by any optional `Check` — it's inherent to
50
+ * attempting a connection at all, so it's never "not covered."
51
+ */
52
+ export declare function computeQualityCoverage(executedChecks: Check[]): QualityCoverage;
28
53
  /**
29
54
  * Aggregate score across every connected server in a report (simple mean —
30
55
  * deliberately not weighted by tool count, so one huge server can't drown
31
- * out the rest of a fleet). Returns `undefined` if no server connected.
56
+ * out the rest of a fleet). `executedChecks` should be the exact list
57
+ * passed to `runChecks({ checks })` for this report, so `coverage` reflects
58
+ * what actually ran (defaults to the full built-in set if omitted, for
59
+ * callers that haven't been updated to pass it through).
60
+ * Returns `undefined` if no server connected — nothing to score at all.
61
+ */
62
+ export declare function computeReportQualityScore(report: RunReport, executedChecks?: Check[]): ReportQualityScore | undefined;
63
+ /**
64
+ * Enforces `.mcp-medic-policy.json`'s `quality.minimumScore` as a CI gate.
65
+ * Pure function so it's directly unit-testable without needing a real CLI
66
+ * invocation or a restricted check set — see PHASE 12 ("policy/CI") in
67
+ * .agent-room/DECISIONS.md.
68
+ *
69
+ * Returns diagnostics to append to the report (0, 1, or 2):
70
+ * - an 'error' if the score is below `minimumScore` (fails the run).
71
+ * - a 'warning' if the score was computed from a partial assessment
72
+ * (incomplete coverage, or a server that couldn't be scored) — a
73
+ * minimumScore gate must never silently "pass" as if a full check had
74
+ * run when it didn't.
32
75
  */
33
- export declare function computeReportQualityScore(report: RunReport): ReportQualityScore | undefined;
76
+ export declare function checkMinimumScorePolicy(quality: ReportQualityScore, minimumScore: number, serverNamesLabel: string): DiagnosticResult[];
@@ -1,4 +1,5 @@
1
- import { categoryOf } from './diagnostics.js';
1
+ import { categoryOf, inferDiagnosticCategory } from './diagnostics.js';
2
+ import { allChecks } from './checks/index.js';
2
3
  export const QUALITY_DIMENSIONS = [
3
4
  'protocol',
4
5
  'schema',
@@ -7,7 +8,8 @@ export const QUALITY_DIMENSIONS = [
7
8
  'reliability',
8
9
  ];
9
10
  /** Suggested weights from the product spec; documented here since every
10
- * point deduction must be explainable in terms of these. */
11
+ * point deduction must be explainable in terms of these. Do not change
12
+ * without a documented reason — see .agent-room/DECISIONS.md. */
11
13
  export const QUALITY_DIMENSION_WEIGHTS = {
12
14
  protocol: 0.25,
13
15
  schema: 0.2,
@@ -15,6 +17,15 @@ export const QUALITY_DIMENSION_WEIGHTS = {
15
17
  security: 0.2,
16
18
  reliability: 0.15,
17
19
  };
20
+ /**
21
+ * The score is a measurement, not a certification: it reports what
22
+ * mcp-medic's passive checks actually found (or, per `coverage`, actually
23
+ * looked for) — never a formal audit of the server's real security or
24
+ * behavior. Surfaced in both JSON (`ReportQualityScore.disclaimer`) and the
25
+ * human report.
26
+ */
27
+ export const QUALITY_SCORE_DISCLAIMER = "This score reflects issues detectable by mcp-medic's passive inspection (see \"coverage\") — " +
28
+ 'it is not a certification of the server\'s actual security, correctness, or behavior.';
18
29
  // Deduction weights: chosen so a handful of real problems meaningfully move
19
30
  // the score, while a single noisy checkId can never wipe out a whole
20
31
  // dimension on its own (the per-checkId cap) — this directly implements
@@ -63,71 +74,23 @@ function deductionsFromDiagnostics(dimension, diagnostics) {
63
74
  }
64
75
  return deductions;
65
76
  }
66
- /** Protocol-dimension facts that live on `MCPConnection` rather than as
67
- * diagnostics (capability negotiation isn't produced by a `Check`). */
68
- function protocolConnectionDeductions(connection) {
69
- const deductions = [];
70
- if (connection.capabilityErrors?.resources) {
71
- deductions.push({
72
- dimension: 'protocol',
73
- checkId: 'protocol.capability-error',
74
- severity: 'warning',
75
- count: 1,
76
- points: 15,
77
- description: 'resources/list failed despite being declared as a capability',
78
- });
79
- }
80
- if (connection.capabilityErrors?.prompts) {
81
- deductions.push({
82
- dimension: 'protocol',
83
- checkId: 'protocol.capability-error',
84
- severity: 'warning',
85
- count: 1,
86
- points: 15,
87
- description: 'prompts/list failed despite being declared as a capability',
88
- });
89
- }
90
- if (connection.protocolVersion &&
91
- connection.protocolVersion.negotiated &&
92
- connection.protocolVersion.negotiated !== connection.protocolVersion.requested) {
93
- deductions.push({
94
- dimension: 'protocol',
95
- checkId: 'protocol.version-downgrade',
96
- severity: 'info',
97
- count: 1,
98
- points: 5,
99
- description: `server negotiated ${connection.protocolVersion.negotiated} instead of the requested ${connection.protocolVersion.requested}`,
100
- });
101
- }
102
- if (!connection.serverInfo?.name) {
103
- deductions.push({
104
- dimension: 'protocol',
105
- checkId: 'protocol.missing-server-info',
106
- severity: 'info',
107
- count: 1,
108
- points: 5,
109
- description: 'server did not report its name/version in serverInfo',
110
- });
111
- }
112
- return deductions;
113
- }
114
77
  function dimensionScore(deductions) {
115
78
  const totalPoints = deductions.reduce((sum, d) => sum + d.points, 0);
116
79
  return Math.max(0, Math.min(100, Math.round(100 - totalPoints)));
117
80
  }
118
81
  /**
119
- * Computes a quality score for one connection. Returns `undefined` for a
120
- * connection that never connected there's nothing meaningful to score
121
- * (no tools/schema/capabilities were ever observed).
82
+ * Computes a quality score for one connection, purely from `diagnostics`
83
+ * (filtered to that server)no connection metadata is consulted
84
+ * directly. Returns `undefined` for a connection that never connected —
85
+ * there's nothing meaningful to score (no tools/schema/capabilities were
86
+ * ever observed); see `computeReportQualityScore` for how the report-level
87
+ * score surfaces that instead of silently dropping it.
122
88
  */
123
89
  export function computeConnectionQualityScore(connection, diagnostics) {
124
90
  if (connection.status !== 'connected')
125
91
  return undefined;
126
92
  const relevant = diagnostics.filter((d) => d.serverName === connection.server.name);
127
- const allDeductions = [
128
- ...protocolConnectionDeductions(connection),
129
- ...QUALITY_DIMENSIONS.flatMap((dim) => deductionsFromDiagnostics(dim, relevant)),
130
- ];
93
+ const allDeductions = QUALITY_DIMENSIONS.flatMap((dim) => deductionsFromDiagnostics(dim, relevant));
131
94
  const dimensions = Object.fromEntries(QUALITY_DIMENSIONS.map((dim) => [dim, dimensionScore(allDeductions.filter((d) => d.dimension === dim))]));
132
95
  const overall = Math.round(QUALITY_DIMENSIONS.reduce((sum, dim) => sum + dimensions[dim] * QUALITY_DIMENSION_WEIGHTS[dim], 0));
133
96
  return {
@@ -136,28 +99,144 @@ export function computeConnectionQualityScore(connection, diagnostics) {
136
99
  deductions: allDeductions.filter((d) => d.points > 0).sort((a, b) => b.points - a.points),
137
100
  };
138
101
  }
102
+ const COVERAGE_WEIGHT = { covered: 1, partial: 0.5, 'not-covered': 0 };
103
+ /**
104
+ * Coverage answers "was this dimension actually evaluated," independent of
105
+ * what score it got — a 100 from zero checks run means "nothing was
106
+ * looked for," not "nothing is wrong." Derived from the *ids* of the
107
+ * checks that actually ran (`executedChecks`), compared against the
108
+ * built-in `allChecks` reference set grouped by dimension. Reliability is
109
+ * special-cased to always 'covered': its signal (did the connection
110
+ * succeed) isn't produced by any optional `Check` — it's inherent to
111
+ * attempting a connection at all, so it's never "not covered."
112
+ */
113
+ export function computeQualityCoverage(executedChecks) {
114
+ const referenceByDimension = new Map();
115
+ for (const check of allChecks) {
116
+ const dim = categoryToDimension(inferDiagnosticCategory(check.id));
117
+ if (dim === 'reliability')
118
+ continue; // no built-in check drives reliability; handled unconditionally below
119
+ if (!referenceByDimension.has(dim))
120
+ referenceByDimension.set(dim, new Set());
121
+ referenceByDimension.get(dim).add(check.id);
122
+ }
123
+ const coverage = {};
124
+ for (const dim of QUALITY_DIMENSIONS) {
125
+ if (dim === 'reliability') {
126
+ coverage[dim] = 'covered';
127
+ continue;
128
+ }
129
+ const reference = referenceByDimension.get(dim) ?? new Set();
130
+ // Every executed check that maps to this dimension — including custom/
131
+ // community checks not in the built-in reference set, so an unmapped
132
+ // check still counts as (at least partial) coverage for the dimension
133
+ // it was inferred into, rather than being invisible to this count.
134
+ const executedForDim = executedChecks.filter((c) => categoryToDimension(inferDiagnosticCategory(c.id)) === dim);
135
+ if (executedForDim.length === 0) {
136
+ coverage[dim] = 'not-covered';
137
+ continue;
138
+ }
139
+ if (reference.size === 0) {
140
+ // No built-in check exists for this dimension at all, but something
141
+ // (necessarily a custom check) ran for it — can't call that "full"
142
+ // coverage without a reference to compare against.
143
+ coverage[dim] = 'partial';
144
+ continue;
145
+ }
146
+ const executedKnownIds = new Set(executedForDim.map((c) => c.id).filter((id) => reference.has(id)));
147
+ coverage[dim] = executedKnownIds.size === reference.size ? 'covered' : 'partial';
148
+ }
149
+ return coverage;
150
+ }
151
+ function coveragePercent(coverage) {
152
+ const total = QUALITY_DIMENSIONS.reduce((sum, dim) => sum + COVERAGE_WEIGHT[coverage[dim]], 0);
153
+ return Math.round((total / QUALITY_DIMENSIONS.length) * 100);
154
+ }
139
155
  /**
140
156
  * Aggregate score across every connected server in a report (simple mean —
141
157
  * deliberately not weighted by tool count, so one huge server can't drown
142
- * out the rest of a fleet). Returns `undefined` if no server connected.
158
+ * out the rest of a fleet). `executedChecks` should be the exact list
159
+ * passed to `runChecks({ checks })` for this report, so `coverage` reflects
160
+ * what actually ran (defaults to the full built-in set if omitted, for
161
+ * callers that haven't been updated to pass it through).
162
+ * Returns `undefined` if no server connected — nothing to score at all.
143
163
  */
144
- export function computeReportQualityScore(report) {
164
+ export function computeReportQualityScore(report, executedChecks = allChecks) {
145
165
  const perServer = {};
146
166
  for (const connection of report.connections) {
147
167
  const breakdown = computeConnectionQualityScore(connection, report.diagnostics);
148
168
  if (breakdown)
149
169
  perServer[connection.server.name] = breakdown;
150
170
  }
151
- const names = Object.keys(perServer);
152
- if (names.length === 0)
171
+ const scoredServers = Object.keys(perServer);
172
+ const unscoredServers = report.connections
173
+ .map((c) => c.server.name)
174
+ .filter((name) => !perServer[name]);
175
+ if (scoredServers.length === 0)
153
176
  return undefined;
154
177
  const dimensions = Object.fromEntries(QUALITY_DIMENSIONS.map((dim) => [
155
178
  dim,
156
- Math.round(names.reduce((sum, n) => sum + perServer[n].dimensions[dim], 0) / names.length),
179
+ Math.round(scoredServers.reduce((sum, n) => sum + perServer[n].dimensions[dim], 0) / scoredServers.length),
157
180
  ]));
158
- const overall = Math.round(names.reduce((sum, n) => sum + perServer[n].overall, 0) / names.length);
159
- const deductions = names
181
+ const overall = Math.round(scoredServers.reduce((sum, n) => sum + perServer[n].overall, 0) / scoredServers.length);
182
+ const deductions = scoredServers
160
183
  .flatMap((n) => perServer[n].deductions)
161
184
  .sort((a, b) => b.points - a.points);
162
- return { overall, dimensions, deductions, perServer };
185
+ const coverage = computeQualityCoverage(executedChecks);
186
+ return {
187
+ overall,
188
+ dimensions,
189
+ deductions,
190
+ perServer,
191
+ coverage,
192
+ coveragePercent: coveragePercent(coverage),
193
+ scoredServers,
194
+ unscoredServers,
195
+ disclaimer: QUALITY_SCORE_DISCLAIMER,
196
+ };
197
+ }
198
+ /**
199
+ * Enforces `.mcp-medic-policy.json`'s `quality.minimumScore` as a CI gate.
200
+ * Pure function so it's directly unit-testable without needing a real CLI
201
+ * invocation or a restricted check set — see PHASE 12 ("policy/CI") in
202
+ * .agent-room/DECISIONS.md.
203
+ *
204
+ * Returns diagnostics to append to the report (0, 1, or 2):
205
+ * - an 'error' if the score is below `minimumScore` (fails the run).
206
+ * - a 'warning' if the score was computed from a partial assessment
207
+ * (incomplete coverage, or a server that couldn't be scored) — a
208
+ * minimumScore gate must never silently "pass" as if a full check had
209
+ * run when it didn't.
210
+ */
211
+ export function checkMinimumScorePolicy(quality, minimumScore, serverNamesLabel) {
212
+ const results = [];
213
+ if (quality.overall < minimumScore) {
214
+ results.push({
215
+ checkId: 'policy.minimum-quality-score',
216
+ severity: 'error',
217
+ message: `MCP quality score ${quality.overall} is below the policy minimum of ${minimumScore}.`,
218
+ serverName: serverNamesLabel,
219
+ category: 'configuration',
220
+ });
221
+ }
222
+ if (quality.coveragePercent < 100 || quality.unscoredServers.length > 0) {
223
+ const gaps = Object.entries(quality.coverage)
224
+ .filter(([, status]) => status !== 'covered')
225
+ .map(([dim, status]) => `${dim}: ${status}`)
226
+ .join(', ');
227
+ const parts = [
228
+ gaps ? `coverage is ${quality.coveragePercent}% (${gaps})` : undefined,
229
+ quality.unscoredServers.length > 0
230
+ ? `${quality.unscoredServers.length} server(s) could not be scored: ${quality.unscoredServers.join(', ')}`
231
+ : undefined,
232
+ ].filter(Boolean);
233
+ results.push({
234
+ checkId: 'policy.partial-coverage-with-minimum-score',
235
+ severity: 'warning',
236
+ message: `quality.minimumScore was evaluated against a partial assessment (${parts.join('; ')}) — it may not reflect a full check of this server.`,
237
+ serverName: serverNamesLabel,
238
+ category: 'configuration',
239
+ });
240
+ }
241
+ return results;
163
242
  }
package/dist/report.js CHANGED
@@ -21,6 +21,24 @@ function formatScoreBlock(label, score) {
21
21
  }
22
22
  return lines;
23
23
  }
24
+ /** Printed once per report (not per-server) — coverage and the disclaimer
25
+ * are report-level facts, and repeating them per server would overwhelm
26
+ * the output for a multi-server fleet. */
27
+ function formatCoverageAndDisclaimer(quality) {
28
+ const lines = [];
29
+ const gaps = Object.entries(quality.coverage).filter(([, status]) => status !== 'covered');
30
+ const coverageLine = gaps.length === 0
31
+ ? `Coverage: ${quality.coveragePercent}% (all dimensions fully evaluated)`
32
+ : `Coverage: ${quality.coveragePercent}% (${gaps
33
+ .map(([dim, status]) => `${DIMENSION_LABELS[dim] ?? dim}: ${status}`)
34
+ .join(', ')})`;
35
+ lines.push(coverageLine);
36
+ if (quality.unscoredServers.length > 0) {
37
+ lines.push(`Note: ${quality.unscoredServers.length} server(s) could not be scored (connection failed): ${quality.unscoredServers.join(', ')}`);
38
+ }
39
+ lines.push(quality.disclaimer);
40
+ return lines;
41
+ }
24
42
  /** Human-readable plain text report formatter. */
25
43
  export function formatReportHuman(report, options = {}) {
26
44
  const lines = [];
@@ -59,9 +77,13 @@ export function formatReportHuman(report, options = {}) {
59
77
  lines.push(...formatScoreBlock('QUALITY', perServerScore));
60
78
  }
61
79
  }
62
- if (options.showScore && report.quality && Object.keys(report.quality.perServer).length > 1) {
80
+ if (options.showScore && report.quality) {
81
+ if (Object.keys(report.quality.perServer).length > 1) {
82
+ lines.push('');
83
+ lines.push(...formatScoreBlock('OVERALL QUALITY (all servers)', report.quality));
84
+ }
63
85
  lines.push('');
64
- lines.push(...formatScoreBlock('OVERALL QUALITY (all servers)', report.quality));
86
+ lines.push(...formatCoverageAndDisclaimer(report.quality));
65
87
  }
66
88
  if (report.diagnostics.length > 0) {
67
89
  lines.push('');
package/dist/types.d.ts CHANGED
@@ -149,9 +149,27 @@ export interface QualityScoreBreakdown {
149
149
  /** Only deductions with points > 0, most-costly first — every deduction here explains itself. */
150
150
  deductions: QualityDeduction[];
151
151
  }
152
+ /** Whether the checks that feed a dimension actually ran this time.
153
+ * 'covered': every built-in check for this dimension ran.
154
+ * 'partial': at least one check contributing to this dimension ran, but not all of them.
155
+ * 'not-covered': no check contributing to this dimension ran — a 100 in
156
+ * that dimension means "nothing flagged it," not "nothing wrong exists." */
157
+ export type CoverageStatus = 'covered' | 'partial' | 'not-covered';
158
+ export type QualityCoverage = Record<QualityDimension, CoverageStatus>;
152
159
  export interface ReportQualityScore extends QualityScoreBreakdown {
153
160
  /** Per-connected-server breakdown; a server that never connected has no entry (nothing to score). */
154
161
  perServer: Record<string, QualityScoreBreakdown>;
162
+ /** Per-dimension coverage, derived from which checks actually ran (`RunOptions.checks`) —
163
+ * never assume a dimension was fully evaluated just because it scored 100. */
164
+ coverage: QualityCoverage;
165
+ /** 0-100: 'covered' dimensions count as 1, 'partial' as 0.5, 'not-covered' as 0, averaged across all 5. */
166
+ coveragePercent: number;
167
+ /** Names of servers actually included in this score (connected + scored). */
168
+ scoredServers: string[];
169
+ /** Names of servers that could NOT be scored (failed to connect) — never silently dropped from view. */
170
+ unscoredServers: string[];
171
+ /** A short, load-bearing reminder of what this number does and doesn't mean — see src/quality-score.ts. */
172
+ disclaimer: string;
155
173
  }
156
174
  export interface RunReport {
157
175
  configSource?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-medic",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
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",