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 +20 -2
- package/dist/checks/index.d.ts +1 -0
- package/dist/checks/index.js +3 -0
- package/dist/checks/protocol-connection-health.d.ts +17 -0
- package/dist/checks/protocol-connection-health.js +78 -0
- package/dist/checks/quality-tool-names.d.ts +33 -0
- package/dist/checks/quality-tool-names.js +87 -48
- package/dist/cli.js +7 -12
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/orchestrator.js +1 -1
- package/dist/protocol/quality-rules.d.ts +46 -0
- package/dist/protocol/quality-rules.js +37 -0
- package/dist/quality-score.d.ts +57 -14
- package/dist/quality-score.js +144 -65
- package/dist/report.js +24 -2
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
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
|
|
package/dist/checks/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/checks/index.js
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
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 constraint — see 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
|
-
|
|
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:
|
|
86
|
-
message:
|
|
124
|
+
severity: finding.severity,
|
|
125
|
+
message: finding.message,
|
|
87
126
|
serverName: connection.server.name,
|
|
88
127
|
toolName: name,
|
|
89
|
-
category:
|
|
90
|
-
confidence:
|
|
91
|
-
suggestedFix: { description:
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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';
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/quality-score.d.ts
CHANGED
|
@@ -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 ->
|
|
8
|
-
* per-dimension score -> weighted overall score.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* .agent-room/DECISIONS.md for the
|
|
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
|
|
15
|
-
* RunReport.quality is one of them; this module
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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).
|
|
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
|
|
76
|
+
export declare function checkMinimumScorePolicy(quality: ReportQualityScore, minimumScore: number, serverNamesLabel: string): DiagnosticResult[];
|
package/dist/quality-score.js
CHANGED
|
@@ -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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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).
|
|
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
|
|
152
|
-
|
|
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(
|
|
179
|
+
Math.round(scoredServers.reduce((sum, n) => sum + perServer[n].dimensions[dim], 0) / scoredServers.length),
|
|
157
180
|
]));
|
|
158
|
-
const overall = Math.round(
|
|
159
|
-
const deductions =
|
|
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
|
-
|
|
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
|
|
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(...
|
|
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;
|