mcp-medic 1.2.3 → 1.2.5

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
 
@@ -6,6 +6,7 @@ export { sampleCallSimulationCheck } from './sample-call-simulation.js';
6
6
  export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
7
7
  export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
8
8
  export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
9
+ export { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
9
10
  export { qualityToolNamesCheck } from './quality-tool-names.js';
10
11
  export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
11
12
  export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
@@ -13,4 +14,5 @@ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
14
  export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
15
  export { qualityResourcesCheck } from './quality-resources.js';
15
16
  export { qualityPromptsCheck } from './quality-prompts.js';
17
+ export { protocolConnectionHealthCheck } from './protocol-connection-health.js';
16
18
  export declare const allChecks: import("../types.js").Check[];
@@ -6,6 +6,7 @@ export { sampleCallSimulationCheck } from './sample-call-simulation.js';
6
6
  export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
7
7
  export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
8
8
  export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
9
+ export { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
9
10
  export { qualityToolNamesCheck } from './quality-tool-names.js';
10
11
  export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
11
12
  export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
@@ -13,6 +14,7 @@ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
14
  export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
15
  export { qualityResourcesCheck } from './quality-resources.js';
15
16
  export { qualityPromptsCheck } from './quality-prompts.js';
17
+ export { protocolConnectionHealthCheck } from './protocol-connection-health.js';
16
18
  import { malformedSchemaCheck } from './malformed-schema.js';
17
19
  import { missingRequiredFieldsCheck } from './missing-required-fields.js';
18
20
  import { typeMismatchCheck } from './type-mismatch.js';
@@ -21,6 +23,7 @@ import { sampleCallSimulationCheck } from './sample-call-simulation.js';
21
23
  import { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
22
24
  import { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
23
25
  import { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
26
+ import { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
24
27
  import { qualityToolNamesCheck } from './quality-tool-names.js';
25
28
  import { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
26
29
  import { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
@@ -28,7 +31,9 @@ import { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
28
31
  import { qualityToolSurfaceCheck } from './quality-tool-surface.js';
29
32
  import { qualityResourcesCheck } from './quality-resources.js';
30
33
  import { qualityPromptsCheck } from './quality-prompts.js';
34
+ import { protocolConnectionHealthCheck } from './protocol-connection-health.js';
31
35
  export const allChecks = [
36
+ protocolConnectionHealthCheck,
32
37
  malformedSchemaCheck,
33
38
  missingRequiredFieldsCheck,
34
39
  typeMismatchCheck,
@@ -37,6 +42,7 @@ export const allChecks = [
37
42
  securityUntrustedRemoteCheck,
38
43
  securityOverbroadPermissionsCheck,
39
44
  securityPromptInjectionRiskCheck,
45
+ securityHiddenUnicodeTagsCheck,
40
46
  qualityToolNamesCheck,
41
47
  qualityToolDescriptionsCheck,
42
48
  qualityToolOutputSchemaCheck,
@@ -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,8 +1,8 @@
1
1
  import type { Check } from '../types.js';
2
2
  /**
3
- * Inspects only what `resources/list` already returned (see
4
- * MCPConnection.resources, populated passively in src/protocol/connect.ts).
5
- * Never calls `resources/read` — that would be real content retrieval, out
6
- * of scope for a passive `check`.
3
+ * Inspects only what `resources/list` and `resources/templates/list` already
4
+ * returned (see MCPConnection.resources/resourceTemplates, populated
5
+ * passively in src/protocol/connect.ts). Never calls `resources/read` —
6
+ * that would be real content retrieval, out of scope for a passive `check`.
7
7
  */
8
8
  export declare const qualityResourcesCheck: Check;
@@ -1,80 +1,135 @@
1
1
  /**
2
- * Inspects only what `resources/list` already returned (see
3
- * MCPConnection.resources, populated passively in src/protocol/connect.ts).
4
- * Never calls `resources/read` — that would be real content retrieval, out
5
- * of scope for a passive `check`.
2
+ * Inspects only what `resources/list` and `resources/templates/list` already
3
+ * returned (see MCPConnection.resources/resourceTemplates, populated
4
+ * passively in src/protocol/connect.ts). Never calls `resources/read` —
5
+ * that would be real content retrieval, out of scope for a passive `check`.
6
6
  */
7
7
  export const qualityResourcesCheck = {
8
8
  id: 'quality.resource',
9
- description: 'Flags duplicate/empty resource URIs, missing required names, and other resources/list quality issues.',
9
+ description: 'Flags duplicate/empty resource(-template) URIs, missing required names, and other resources/list quality issues.',
10
10
  run(connection) {
11
11
  const results = [];
12
12
  try {
13
13
  const resources = connection.resources;
14
- if (!resources || !Array.isArray(resources)) {
15
- return results;
16
- }
17
- const byUri = new Map();
18
- for (const resource of resources) {
19
- const uri = resource.uri;
20
- if (typeof uri !== 'string' || uri.trim() === '') {
21
- results.push({
22
- checkId: 'quality.resource',
23
- severity: 'error',
24
- message: 'Resource has an empty or invalid "uri" — the MCP spec requires resources to have a URI.',
25
- serverName: connection.server.name,
26
- category: 'schema',
27
- details: { resource },
28
- });
29
- continue;
30
- }
31
- byUri.set(uri, (byUri.get(uri) ?? 0) + 1);
32
- // Per the MCP spec's Resource type (extends BaseMetadata), "name" is
33
- // a required field, not optional.
34
- if (!resource.name || resource.name.trim() === '') {
35
- results.push({
36
- checkId: 'quality.resource',
37
- severity: 'error',
38
- message: `Resource "${uri}" is missing a "name" — required by the MCP spec's Resource type.`,
39
- serverName: connection.server.name,
40
- category: 'schema',
41
- details: { uri },
42
- suggestedFix: { description: `Add a "name" field to the resource at "${uri}".` },
43
- });
14
+ if (Array.isArray(resources)) {
15
+ const byUri = new Map();
16
+ for (const resource of resources) {
17
+ const uri = resource.uri;
18
+ if (typeof uri !== 'string' || uri.trim() === '') {
19
+ results.push({
20
+ checkId: 'quality.resource',
21
+ severity: 'error',
22
+ message: 'Resource has an empty or invalid "uri" — the MCP spec requires resources to have a URI.',
23
+ serverName: connection.server.name,
24
+ category: 'schema',
25
+ details: { resource },
26
+ });
27
+ continue;
28
+ }
29
+ byUri.set(uri, (byUri.get(uri) ?? 0) + 1);
30
+ // Per the MCP spec's Resource type (extends BaseMetadata), "name" is
31
+ // a required field, not optional.
32
+ if (!resource.name || resource.name.trim() === '') {
33
+ results.push({
34
+ checkId: 'quality.resource',
35
+ severity: 'error',
36
+ message: `Resource "${uri}" is missing a "name" — required by the MCP spec's Resource type.`,
37
+ serverName: connection.server.name,
38
+ category: 'schema',
39
+ details: { uri },
40
+ suggestedFix: { description: `Add a "name" field to the resource at "${uri}".` },
41
+ });
42
+ }
43
+ // description is optional per spec — absence is a quality
44
+ // recommendation, not a violation.
45
+ if (!resource.description || resource.description.trim() === '') {
46
+ results.push({
47
+ checkId: 'quality.resource',
48
+ severity: 'info',
49
+ message: `Resource "${uri}" has no description, making it harder for an agent to know when to read it.`,
50
+ serverName: connection.server.name,
51
+ category: 'quality',
52
+ details: { uri },
53
+ });
54
+ }
55
+ if (resource.size !== undefined && (typeof resource.size !== 'number' || resource.size < 0)) {
56
+ results.push({
57
+ checkId: 'quality.resource',
58
+ severity: 'warning',
59
+ message: `Resource "${uri}" declares an invalid "size" (${JSON.stringify(resource.size)}) — size must be a non-negative number.`,
60
+ serverName: connection.server.name,
61
+ category: 'schema',
62
+ details: { uri, size: resource.size },
63
+ });
64
+ }
44
65
  }
45
- // description is optional per spec — absence is a quality
46
- // recommendation, not a violation.
47
- if (!resource.description || resource.description.trim() === '') {
48
- results.push({
49
- checkId: 'quality.resource',
50
- severity: 'info',
51
- message: `Resource "${uri}" has no description, making it harder for an agent to know when to read it.`,
52
- serverName: connection.server.name,
53
- category: 'quality',
54
- details: { uri },
55
- });
56
- }
57
- if (resource.size !== undefined && (typeof resource.size !== 'number' || resource.size < 0)) {
58
- results.push({
59
- checkId: 'quality.resource',
60
- severity: 'warning',
61
- message: `Resource "${uri}" declares an invalid "size" (${JSON.stringify(resource.size)}) — size must be a non-negative number.`,
62
- serverName: connection.server.name,
63
- category: 'schema',
64
- details: { uri, size: resource.size },
65
- });
66
+ for (const [uri, count] of byUri) {
67
+ if (count > 1) {
68
+ results.push({
69
+ checkId: 'quality.resource',
70
+ severity: 'error',
71
+ message: `Resource URI "${uri}" is declared ${count} times — resource URIs must be unique.`,
72
+ serverName: connection.server.name,
73
+ category: 'schema',
74
+ details: { uri, duplicateCount: count },
75
+ });
76
+ }
66
77
  }
67
78
  }
68
- for (const [uri, count] of byUri) {
69
- if (count > 1) {
70
- results.push({
71
- checkId: 'quality.resource',
72
- severity: 'error',
73
- message: `Resource URI "${uri}" is declared ${count} times resource URIs must be unique.`,
74
- serverName: connection.server.name,
75
- category: 'schema',
76
- details: { uri, duplicateCount: count },
77
- });
79
+ const templates = connection.resourceTemplates;
80
+ if (Array.isArray(templates)) {
81
+ const byUriTemplate = new Map();
82
+ for (const template of templates) {
83
+ const uriTemplate = template.uriTemplate;
84
+ if (typeof uriTemplate !== 'string' || uriTemplate.trim() === '') {
85
+ results.push({
86
+ checkId: 'quality.resource',
87
+ severity: 'error',
88
+ message: 'Resource template has an empty or invalid "uriTemplate" — the MCP spec requires resource templates to have one.',
89
+ serverName: connection.server.name,
90
+ category: 'schema',
91
+ details: { template },
92
+ });
93
+ continue;
94
+ }
95
+ byUriTemplate.set(uriTemplate, (byUriTemplate.get(uriTemplate) ?? 0) + 1);
96
+ // Per the MCP spec's ResourceTemplate type (extends BaseMetadata),
97
+ // "name" is a required field, not optional.
98
+ if (!template.name || template.name.trim() === '') {
99
+ results.push({
100
+ checkId: 'quality.resource',
101
+ severity: 'error',
102
+ message: `Resource template "${uriTemplate}" is missing a "name" — required by the MCP spec's ResourceTemplate type.`,
103
+ serverName: connection.server.name,
104
+ category: 'schema',
105
+ details: { uriTemplate },
106
+ suggestedFix: { description: `Add a "name" field to the resource template "${uriTemplate}".` },
107
+ });
108
+ }
109
+ // description is optional per spec — absence is a quality
110
+ // recommendation, not a violation.
111
+ if (!template.description || template.description.trim() === '') {
112
+ results.push({
113
+ checkId: 'quality.resource',
114
+ severity: 'info',
115
+ message: `Resource template "${uriTemplate}" has no description, making it harder for an agent to know when to use it.`,
116
+ serverName: connection.server.name,
117
+ category: 'quality',
118
+ details: { uriTemplate },
119
+ });
120
+ }
121
+ }
122
+ for (const [uriTemplate, count] of byUriTemplate) {
123
+ if (count > 1) {
124
+ results.push({
125
+ checkId: 'quality.resource',
126
+ severity: 'error',
127
+ message: `Resource template "${uriTemplate}" is declared ${count} times — resource template URIs must be unique.`,
128
+ serverName: connection.server.name,
129
+ category: 'schema',
130
+ details: { uriTemplate, duplicateCount: count },
131
+ });
132
+ }
78
133
  }
79
134
  }
80
135
  }
@@ -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
  }