mcp-medic 1.2.2 → 1.2.3

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
@@ -4,9 +4,9 @@
4
4
  [![npm version](https://img.shields.io/npm/v/mcp-medic.svg)](https://www.npmjs.com/package/mcp-medic)
5
5
  [![license](https://img.shields.io/npm/l/mcp-medic.svg)](./LICENSE)
6
6
 
7
- Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.
7
+ An MCP quality gate for developers and CI — not just "does my config parse," but "is this MCP server high-quality, safe, well-described, and usable by an AI agent."
8
8
 
9
- `mcp-medic` validates MCP server configurations, executes full protocol initialization handshakes across stdio/SSE/HTTP transports, checks all exposed tool JSON schemas against standard specifications, and simulates sample calls — providing actionable suggestions and CI-ready exit codes.
9
+ `mcp-medic` validates MCP server configurations, executes full protocol initialization handshakes across stdio/SSE/HTTP transports (with real protocol version negotiation), passively inspects every declared tool/resource/prompt, checks JSON schemas against the MCP spec, and computes a deterministic 0–100 **MCP Quality Score** — providing actionable diagnostics, a stable diagnostic taxonomy, and CI-ready exit codes.
10
10
 
11
11
  > [!NOTE]
12
12
  > **Naming & Installation**: The npm package for this tool is **`mcp-medic`** (`npx mcp-medic` / `npm i -g mcp-medic`). While this GitHub repository is named `mcp-doctor`, an unrelated older package already occupies the npm name `mcp-doctor` (different author). Users who want this tool must install **`mcp-medic`**, not `mcp-doctor`.
@@ -27,16 +27,18 @@ This is the whole point (a real handshake, not a schema guess) — but it means
27
27
 
28
28
  ## Features
29
29
 
30
+ - 🏆 **MCP Quality Score**: a deterministic 0–100 score across five weighted dimensions (Protocol, Schema, Agent usability, Security, Reliability) via `mcp-medic score <config>` or `check --score`. No LLM, no randomness — every point is traced back to a real diagnostic.
31
+ - 🧠 **Tool/Resource/Prompt Quality Checks**: flags empty/duplicate/placeholder tool names, vague or placeholder descriptions, malformed input/output schemas, contradictory tool annotations, excessive tool surface (bloat), and duplicate/malformed resources and prompts.
30
32
  - 🔍 **Auto-Discovery**: Run `mcp-medic check` with no arguments to auto-discover Claude Desktop, `.mcp.json`, and VS Code/Cursor MCP configuration paths across macOS, Windows, and Linux.
31
33
  - 💡 **Auto-Fix Suggestions**: Diagnose issues with clear, actionable fix suggestions using `--show-fixes`.
32
34
  - 🌐 **Registry Validation**: Validate published registry entries directly using `mcp-medic check --registry <server-id>`.
33
35
  - 🧪 **Fleet Validation** (experimental): Scan and validate monorepos or multi-team configurations with `mcp-medic check-all "<glob>"`.
34
36
  - 🧪 **Drift Detection** (experimental): Catch environment divergence between staging and production configs with `mcp-medic diff <configA> <configB>`.
35
- - 📜 **Policy-as-Code**: Enforce organizational constraints (e.g., banned transports, domain allowlists, minimum description lengths) via `.mcp-medic-policy.json` / `--policy`.
37
+ - 📜 **Policy-as-Code**: Enforce organizational constraints (banned transports, domain allowlists, minimum description lengths, a minimum quality score, a max tool count, required tool descriptions) via `.mcp-medic-policy.json` / `--policy`.
36
38
  - 📸 **Snapshot Baseline Mode**: Filter out legacy diagnostics with `--snapshot <baseline.json>` to gate only on newly introduced regressions.
37
- - 📊 **CI Reporting**: Export standard JUnit XML (`--export-junit <file.xml>`) and JSON (`--export-json <file.json>`) for seamless CI dashboard visualization.
39
+ - 📊 **CI Reporting**: Export JUnit XML (`--export-junit`), JSON (`--export-json`, always includes the quality score), and SARIF 2.1.0 (`--export-sarif`, for GitHub Code Scanning).
38
40
  - 👀 **Watch Mode**: Re-run validation on save using `mcp-medic watch <path>`.
39
- - ⚡ **Transport Hardening**: Full handshake validation across stdio, HTTP (with OAuth token refresh), and SSE (with automatic retry resilience).
41
+ - ⚡ **Protocol Correctness**: real protocol version negotiation (client requests a version, server's actual negotiated version is validated — not assumed), full handshake validation across stdio, HTTP (with OAuth token refresh), and SSE (with automatic retry resilience), plus passive `resources/list`/`prompts/list` capability inspection.
40
42
  - 🧪 **VS Code Extension** (experimental, not yet on the Marketplace): in-editor squiggles and hover tooltips — runnable from source today, see [vscode-extension/](./vscode-extension/).
41
43
  - 🚦 **CI Usability & Exit Codes**: Strict exit code taxonomy (`0` clean, `1` diagnostic failures, `2` usage/syntax errors) and `--fail-on <error|warning>`.
42
44
  - 🤖 **GitHub Action**: Drop-in CI integration via `shivam039/mcp-doctor@main` (or `mcp-medic-action`).
@@ -44,6 +46,36 @@ This is the whole point (a real handshake, not a schema guess) — but it means
44
46
 
45
47
  ---
46
48
 
49
+ ## MCP Quality Engine
50
+
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
+
53
+ ```bash
54
+ mcp-medic score path/to/config.json
55
+ # or, alongside the normal report:
56
+ mcp-medic check path/to/config.json --score
57
+ ```
58
+
59
+ ### Dimensions
60
+
61
+ | Dimension | Weight | What it reflects |
62
+ |---|---|---|
63
+ | Protocol | 25% | Version negotiation compatibility, capability-inspection health (`resources/list`/`prompts/list` succeeding), `serverInfo` presence |
64
+ | Schema | 20% | `schema.*` diagnostics — malformed/missing input schemas, type mismatches, missing required fields |
65
+ | Agent usability | 20% | Tool/resource/prompt naming, description quality, output schemas, annotations, tool-surface bloat |
66
+ | Security | 20% | `security.*` heuristic diagnostics (untrusted remotes, overbroad permissions, prompt-injection-risk patterns) |
67
+ | Reliability | 15% | Whether the server connects at all (currently binary — see [Known Limitations](#known-limitations)) |
68
+
69
+ ### How deductions work
70
+
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.
72
+
73
+ ### Diagnostic categories
74
+
75
+ Every diagnostic has a stable category (`protocol`, `schema`, `quality`, `security`, `reliability`, `usability`, `configuration`) — set explicitly by newer checks, or inferred from the `checkId` prefix for older ones, so nothing that already worked had to change.
76
+
77
+ ---
78
+
47
79
  ## Quick Start
48
80
 
49
81
  > **Preferred invocation**: Run `npx mcp-medic` (the npm package is `mcp-medic`, not `mcp-doctor`).
@@ -71,6 +103,9 @@ npx mcp-medic check path/to/config.json --policy .mcp-medic-policy.json --export
71
103
  # Display suggested fixes for flagged diagnostics
72
104
  npx mcp-medic check path/to/config.json --show-fixes
73
105
 
106
+ # Compute the deterministic MCP quality score
107
+ npx mcp-medic score path/to/config.json
108
+
74
109
  # Watch mode (re-runs checks on save)
75
110
  npx mcp-medic watch path/to/config.json
76
111
  ```
@@ -87,12 +122,16 @@ npx mcp-medic watch path/to/config.json
87
122
  | `check --registry <id>` | Validate a published registry server directly |
88
123
  | `watch <path>` | Watch configuration file and re-run checks on file save |
89
124
  | `fix <path>` | Interactively apply mechanical suggested fixes (see [Auto-Fix](#auto-fix-mcp-medic-fix) below) |
125
+ | `score <path>` | Print the report with the MCP quality score section (same as `check --score`) |
90
126
  | `--config <path>` | Explicit configuration path |
91
127
  | `--policy <path>` | Apply organizational policy rules (`.mcp-medic-policy.json`) |
92
128
  | `--snapshot <path>` | Compare against baseline snapshot, reporting regressions only |
93
129
  | `--update-snapshot <path>` | Save diagnostic report as new baseline snapshot |
94
130
  | `--export-junit <file>` | Export report in JUnit XML format |
95
- | `--export-json <file>` | Export report in JSON format |
131
+ | `--export-json <file>` | Export report in JSON format (always includes `quality`) |
132
+ | `--export-sarif <file>` | Export report in SARIF 2.1.0 format (GitHub Code Scanning, etc.) |
133
+ | `--score` | Include the MCP quality score section in the human report |
134
+ | `--protocol-version <v>` | MCP protocolVersion to request: `auto` (default) or an explicit version |
96
135
  | `--show-fixes` | Show suggested fixes inline under diagnostics |
97
136
  | `--fail-on <severity>` | Fail with exit code 1 on `error` (default) or `warning` |
98
137
  | `--verbose`, `-v` | Output raw JSON-RPC traffic and debug messages |
@@ -137,10 +176,17 @@ Define organization-wide policies that compose with built-in checks:
137
176
  {
138
177
  "bannedTransports": ["stdio"],
139
178
  "allowedDomains": ["corp.internal", "mcp.example.com"],
140
- "minDescriptionLength": 20
179
+ "minDescriptionLength": 20,
180
+ "quality": {
181
+ "minimumScore": 80,
182
+ "maxTools": 100,
183
+ "requireToolDescriptions": true
184
+ }
141
185
  }
142
186
  ```
143
187
 
188
+ `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
+
144
190
  ---
145
191
 
146
192
  ## VS Code Extension (experimental)
@@ -219,7 +265,14 @@ The action adheres to strict exit code taxonomy:
219
265
  | `security.untrusted-remote` | `mcp-medic` | **Official** (heuristic) | Flags non-HTTPS or raw-IP SSE/HTTP server URLs |
220
266
  | `security.overbroad-permissions` | `mcp-medic` | **Official** (heuristic) | Flags tools with unscoped shell/filesystem/network parameters |
221
267
  | `security.prompt-injection-risk` | `mcp-medic` | **Official** (heuristic) | Flags instruction-like language in tool descriptions aimed at the model |
222
- | `policy.*` | `mcp-medic` | **Official** | Evaluates policy-as-code rules (transports, domains, length) |
268
+ | `quality.tool-name` | `mcp-medic` | **Official** | Flags empty/duplicate (error) and overly-long/ambiguous/placeholder (warning) tool names |
269
+ | `quality.vague-description` | `mcp-medic` | **Official** | Flags present-but-vague tool descriptions (placeholder text, single words, name repeated as description) |
270
+ | `quality.output-schema` | `mcp-medic` | **Official** | Flags a malformed `outputSchema` — never flags its absence, which is optional per spec |
271
+ | `quality.tool-annotations` | `mcp-medic` | **Official** | Flags internally contradictory `ToolAnnotations` hints (e.g. both read-only and destructive) |
272
+ | `quality.tool-surface` | `mcp-medic` | **Official** | Flags excessive tool counts (default 100) and near-duplicate names/descriptions |
273
+ | `quality.resource` | `mcp-medic` | **Official** | Flags duplicate/empty resource URIs and missing required `name` from passive `resources/list` results |
274
+ | `quality.prompt` | `mcp-medic` | **Official** | Flags duplicate/empty prompt or argument names and placeholder descriptions from passive `prompts/list` results |
275
+ | `policy.*` | `mcp-medic` | **Official** | Evaluates policy-as-code rules (transports, domains, length, quality thresholds) |
223
276
  | `community.strict-typing` | `mcp-medic-check-strict-typing` | *Planned / example* | Would enforce strict property type annotations |
224
277
  | `community.no-empty-enums` | `mcp-medic-check-no-empty-enums` | *Planned / example* | Would ensure non-empty enum option lists |
225
278
 
@@ -236,6 +289,9 @@ The `security.*` checks are heuristic — they pattern-match on what a server *d
236
289
  - **`security.*` checks are heuristic pattern-matching**, not a security audit — see the note above. They can both miss real issues and flag benign configs (e.g. a legitimate local dev server on plain `http://`).
237
290
  - **Fleet commands (`check-all`, `diff`) are newer and less battle-tested** than `check`/`watch` — the core check pipeline they're built on is the same, but edge cases in glob matching or drift diffing are more likely.
238
291
  - **The VS Code extension and community check packages are not shipped/published** — see the sections above.
292
+ - **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
+ - **`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
+ - **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.
239
295
  - **npm README sync**: Latest docs live on GitHub main; npm README updates on the next publish.
240
296
  - **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.
241
297
 
@@ -6,4 +6,11 @@ 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 { qualityToolNamesCheck } from './quality-tool-names.js';
10
+ export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
11
+ export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
12
+ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
+ export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
+ export { qualityResourcesCheck } from './quality-resources.js';
15
+ export { qualityPromptsCheck } from './quality-prompts.js';
9
16
  export declare const allChecks: import("../types.js").Check[];
@@ -6,6 +6,13 @@ 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 { qualityToolNamesCheck } from './quality-tool-names.js';
10
+ export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
11
+ export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
12
+ export { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
13
+ export { qualityToolSurfaceCheck, createToolSurfaceCheck, DEFAULT_MAX_TOOLS_WARNING_THRESHOLD } from './quality-tool-surface.js';
14
+ export { qualityResourcesCheck } from './quality-resources.js';
15
+ export { qualityPromptsCheck } from './quality-prompts.js';
9
16
  import { malformedSchemaCheck } from './malformed-schema.js';
10
17
  import { missingRequiredFieldsCheck } from './missing-required-fields.js';
11
18
  import { typeMismatchCheck } from './type-mismatch.js';
@@ -14,6 +21,13 @@ import { sampleCallSimulationCheck } from './sample-call-simulation.js';
14
21
  import { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
15
22
  import { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
16
23
  import { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
24
+ import { qualityToolNamesCheck } from './quality-tool-names.js';
25
+ import { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
26
+ import { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
27
+ import { qualityToolAnnotationsCheck } from './quality-tool-annotations.js';
28
+ import { qualityToolSurfaceCheck } from './quality-tool-surface.js';
29
+ import { qualityResourcesCheck } from './quality-resources.js';
30
+ import { qualityPromptsCheck } from './quality-prompts.js';
17
31
  export const allChecks = [
18
32
  malformedSchemaCheck,
19
33
  missingRequiredFieldsCheck,
@@ -23,4 +37,11 @@ export const allChecks = [
23
37
  securityUntrustedRemoteCheck,
24
38
  securityOverbroadPermissionsCheck,
25
39
  securityPromptInjectionRiskCheck,
40
+ qualityToolNamesCheck,
41
+ qualityToolDescriptionsCheck,
42
+ qualityToolOutputSchemaCheck,
43
+ qualityToolAnnotationsCheck,
44
+ qualityToolSurfaceCheck,
45
+ qualityResourcesCheck,
46
+ qualityPromptsCheck,
26
47
  ];
@@ -48,6 +48,23 @@ export const malformedSchemaCheck = {
48
48
  },
49
49
  });
50
50
  }
51
+ else if (typeof schemaObj.type === 'string' && schemaObj.type !== 'object') {
52
+ // Per the MCP spec, a tool's inputSchema MUST describe an object
53
+ // (tool arguments are always passed as a JSON object) — a
54
+ // top-level type other than "object" is a protocol violation,
55
+ // not merely a style issue.
56
+ results.push({
57
+ checkId: 'schema.malformed',
58
+ severity: 'error',
59
+ message: `Tool "${tool.name}" inputSchema declares type "${schemaObj.type}", but the MCP spec requires tool inputSchema to be type "object".`,
60
+ serverName: connection.server.name,
61
+ toolName: tool.name,
62
+ details: { inputSchema: schema },
63
+ suggestedFix: {
64
+ description: 'Change inputSchema\'s top-level "type" to "object".',
65
+ },
66
+ });
67
+ }
51
68
  }
52
69
  }
53
70
  catch (err) {
@@ -0,0 +1,8 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * Inspects only what `prompts/list` already returned (see
4
+ * MCPConnection.prompts, populated passively in src/protocol/connect.ts).
5
+ * Never calls `prompts/get` — that would retrieve/render the prompt, out
6
+ * of scope for a passive `check`.
7
+ */
8
+ export declare const qualityPromptsCheck: Check;
@@ -0,0 +1,121 @@
1
+ /** Same conservative placeholder list used for tool descriptions. */
2
+ const PLACEHOLDER_DESCRIPTIONS = new Set([
3
+ 'todo',
4
+ 'test',
5
+ 'foo',
6
+ 'bar',
7
+ 'description',
8
+ 'prompt',
9
+ 'tbd',
10
+ 'n/a',
11
+ 'na',
12
+ 'none',
13
+ 'placeholder',
14
+ 'xxx',
15
+ ]);
16
+ /**
17
+ * Inspects only what `prompts/list` already returned (see
18
+ * MCPConnection.prompts, populated passively in src/protocol/connect.ts).
19
+ * Never calls `prompts/get` — that would retrieve/render the prompt, out
20
+ * of scope for a passive `check`.
21
+ */
22
+ export const qualityPromptsCheck = {
23
+ id: 'quality.prompt',
24
+ description: 'Flags duplicate/empty prompt names, placeholder descriptions, and malformed argument definitions in prompts/list results.',
25
+ run(connection) {
26
+ const results = [];
27
+ try {
28
+ const prompts = connection.prompts;
29
+ if (!prompts || !Array.isArray(prompts)) {
30
+ return results;
31
+ }
32
+ const byName = new Map();
33
+ for (const prompt of prompts) {
34
+ const name = prompt.name;
35
+ if (typeof name !== 'string' || name.trim() === '') {
36
+ results.push({
37
+ checkId: 'quality.prompt',
38
+ severity: 'error',
39
+ message: 'Prompt has an empty or invalid "name" — the MCP spec requires prompts to have a name.',
40
+ serverName: connection.server.name,
41
+ category: 'schema',
42
+ details: { prompt },
43
+ });
44
+ continue;
45
+ }
46
+ byName.set(name, (byName.get(name) ?? 0) + 1);
47
+ const description = prompt.description?.trim();
48
+ if (!description) {
49
+ results.push({
50
+ checkId: 'quality.prompt',
51
+ severity: 'info',
52
+ message: `Prompt "${name}" has no description, making it harder for an agent to know when to use it.`,
53
+ serverName: connection.server.name,
54
+ category: 'quality',
55
+ });
56
+ }
57
+ else if (PLACEHOLDER_DESCRIPTIONS.has(description.toLowerCase())) {
58
+ results.push({
59
+ checkId: 'quality.prompt',
60
+ severity: 'warning',
61
+ message: `Prompt "${name}" description ("${description}") looks like placeholder text.`,
62
+ serverName: connection.server.name,
63
+ category: 'quality',
64
+ confidence: 'high',
65
+ });
66
+ }
67
+ if (Array.isArray(prompt.arguments)) {
68
+ const argNames = new Map();
69
+ for (const arg of prompt.arguments) {
70
+ if (!arg.name || arg.name.trim() === '') {
71
+ results.push({
72
+ checkId: 'quality.prompt',
73
+ severity: 'error',
74
+ message: `Prompt "${name}" has an argument with an empty or missing "name" — required by the MCP spec's PromptArgument type.`,
75
+ serverName: connection.server.name,
76
+ category: 'schema',
77
+ details: { promptName: name, argument: arg },
78
+ });
79
+ continue;
80
+ }
81
+ argNames.set(arg.name, (argNames.get(arg.name) ?? 0) + 1);
82
+ }
83
+ for (const [argName, count] of argNames) {
84
+ if (count > 1) {
85
+ results.push({
86
+ checkId: 'quality.prompt',
87
+ severity: 'error',
88
+ message: `Prompt "${name}" declares argument "${argName}" ${count} times.`,
89
+ serverName: connection.server.name,
90
+ category: 'schema',
91
+ details: { promptName: name, argumentName: argName, duplicateCount: count },
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ for (const [name, count] of byName) {
98
+ if (count > 1) {
99
+ results.push({
100
+ checkId: 'quality.prompt',
101
+ severity: 'error',
102
+ message: `Prompt name "${name}" is declared ${count} times — a client cannot reliably invoke a specific one by name.`,
103
+ serverName: connection.server.name,
104
+ category: 'schema',
105
+ details: { duplicateCount: count },
106
+ });
107
+ }
108
+ }
109
+ }
110
+ catch (err) {
111
+ results.push({
112
+ checkId: 'quality.prompt',
113
+ severity: 'error',
114
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
115
+ serverName: connection.server.name,
116
+ category: 'quality',
117
+ });
118
+ }
119
+ return results;
120
+ },
121
+ };
@@ -0,0 +1,8 @@
1
+ import type { Check } from '../types.js';
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`.
7
+ */
8
+ export declare const qualityResourcesCheck: Check;
@@ -0,0 +1,92 @@
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`.
6
+ */
7
+ export const qualityResourcesCheck = {
8
+ id: 'quality.resource',
9
+ description: 'Flags duplicate/empty resource URIs, missing required names, and other resources/list quality issues.',
10
+ run(connection) {
11
+ const results = [];
12
+ try {
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
+ });
44
+ }
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
+ }
67
+ }
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
+ });
78
+ }
79
+ }
80
+ }
81
+ catch (err) {
82
+ results.push({
83
+ checkId: 'quality.resource',
84
+ severity: 'error',
85
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
86
+ serverName: connection.server.name,
87
+ category: 'quality',
88
+ });
89
+ }
90
+ return results;
91
+ },
92
+ };
@@ -0,0 +1,10 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * MCP `ToolAnnotations` are optional client *hints* the spec explicitly
4
+ * does not treat as authoritative — a server can declare `readOnlyHint:
5
+ * true` and still do something destructive. This check only flags
6
+ * internally self-contradictory combinations of hints (the server's own
7
+ * declaration doesn't add up), never infers "this tool is dangerous" from
8
+ * an annotation, and never treats annotations as a security guarantee.
9
+ */
10
+ export declare const qualityToolAnnotationsCheck: Check;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * MCP `ToolAnnotations` are optional client *hints* the spec explicitly
3
+ * does not treat as authoritative — a server can declare `readOnlyHint:
4
+ * true` and still do something destructive. This check only flags
5
+ * internally self-contradictory combinations of hints (the server's own
6
+ * declaration doesn't add up), never infers "this tool is dangerous" from
7
+ * an annotation, and never treats annotations as a security guarantee.
8
+ */
9
+ export const qualityToolAnnotationsCheck = {
10
+ id: 'quality.tool-annotations',
11
+ description: 'Flags tool annotation hints that are internally contradictory (e.g. both read-only and destructive).',
12
+ run(connection) {
13
+ const results = [];
14
+ try {
15
+ if (!connection.tools || !Array.isArray(connection.tools)) {
16
+ return results;
17
+ }
18
+ for (const tool of connection.tools) {
19
+ const annotations = tool.annotations;
20
+ if (!annotations || typeof annotations !== 'object')
21
+ continue;
22
+ if (annotations.readOnlyHint === true && annotations.destructiveHint === true) {
23
+ results.push({
24
+ checkId: 'quality.tool-annotations',
25
+ severity: 'warning',
26
+ message: `Tool "${tool.name}" declares both readOnlyHint and destructiveHint as true — these are contradictory (a read-only tool cannot also be destructive).`,
27
+ serverName: connection.server.name,
28
+ toolName: tool.name,
29
+ category: 'quality',
30
+ confidence: 'high',
31
+ suggestedFix: {
32
+ description: `Correct "${tool.name}"'s annotations so readOnlyHint and destructiveHint aren't both true.`,
33
+ },
34
+ });
35
+ }
36
+ if (annotations.readOnlyHint === true && annotations.idempotentHint === false) {
37
+ // Not a hard contradiction (idempotentHint's meaning is about repeat
38
+ // calls with the same args), but worth a low-confidence note: a
39
+ // read-only operation is idempotent by construction in practice.
40
+ results.push({
41
+ checkId: 'quality.tool-annotations',
42
+ severity: 'info',
43
+ message: `Tool "${tool.name}" declares readOnlyHint: true but idempotentHint: false — read-only operations are usually idempotent; double-check this is intentional.`,
44
+ serverName: connection.server.name,
45
+ toolName: tool.name,
46
+ category: 'quality',
47
+ confidence: 'low',
48
+ });
49
+ }
50
+ }
51
+ }
52
+ catch (err) {
53
+ results.push({
54
+ checkId: 'quality.tool-annotations',
55
+ severity: 'error',
56
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
57
+ serverName: connection.server.name,
58
+ category: 'quality',
59
+ });
60
+ }
61
+ return results;
62
+ },
63
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const qualityToolDescriptionsCheck: Check;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Complements `schema.missing-description` (which flags *absent* descriptions).
3
+ * This check only looks at descriptions that ARE present, and flags ones that
4
+ * exist but carry no real semantic information — the "someone typed
5
+ * something to satisfy a linter" case. Deterministic heuristics only, no LLM;
6
+ * conservative by design so legitimately short-but-meaningful descriptions
7
+ * (e.g. "Returns the current time.") are never flagged.
8
+ */
9
+ const PLACEHOLDER_DESCRIPTIONS = new Set([
10
+ 'todo',
11
+ 'test',
12
+ 'foo',
13
+ 'bar',
14
+ 'description',
15
+ 'tool',
16
+ 'tbd',
17
+ 'n/a',
18
+ 'na',
19
+ 'none',
20
+ 'placeholder',
21
+ 'xxx',
22
+ 'wip',
23
+ 'change me',
24
+ 'fill me in',
25
+ 'description here',
26
+ 'a tool',
27
+ 'this is a tool',
28
+ ]);
29
+ function wordCount(text) {
30
+ return text.split(/\s+/).filter(Boolean).length;
31
+ }
32
+ export const qualityToolDescriptionsCheck = {
33
+ id: 'quality.vague-description',
34
+ description: 'Flags tool descriptions that are present but carry no useful semantic information (placeholder text, single words, or a copy of the tool name).',
35
+ run(connection) {
36
+ const results = [];
37
+ try {
38
+ if (!connection.tools || !Array.isArray(connection.tools)) {
39
+ return results;
40
+ }
41
+ for (const tool of connection.tools) {
42
+ const description = tool.description;
43
+ if (typeof description !== 'string')
44
+ continue; // absence is schema.missing-description's job
45
+ const trimmed = description.trim();
46
+ if (trimmed === '')
47
+ continue; // ditto
48
+ const normalized = trimmed.toLowerCase();
49
+ if (PLACEHOLDER_DESCRIPTIONS.has(normalized)) {
50
+ results.push({
51
+ checkId: 'quality.vague-description',
52
+ severity: 'warning',
53
+ message: `Tool "${tool.name}" description ("${trimmed}") looks like placeholder text, not a real description.`,
54
+ serverName: connection.server.name,
55
+ toolName: tool.name,
56
+ category: 'quality',
57
+ confidence: 'high',
58
+ suggestedFix: {
59
+ description: `Write a real description for "${tool.name}" explaining what it does and when an agent should call it.`,
60
+ },
61
+ });
62
+ continue;
63
+ }
64
+ if (normalized === tool.name.trim().toLowerCase()) {
65
+ results.push({
66
+ checkId: 'quality.vague-description',
67
+ severity: 'warning',
68
+ message: `Tool "${tool.name}" description is just the tool's own name — it adds no information beyond what the name already says.`,
69
+ serverName: connection.server.name,
70
+ toolName: tool.name,
71
+ category: 'quality',
72
+ confidence: 'high',
73
+ suggestedFix: {
74
+ description: `Describe what "${tool.name}" actually does, not just repeat its name.`,
75
+ },
76
+ });
77
+ continue;
78
+ }
79
+ if (wordCount(trimmed) <= 1) {
80
+ results.push({
81
+ checkId: 'quality.vague-description',
82
+ severity: 'warning',
83
+ message: `Tool "${tool.name}" description ("${trimmed}") is a single word — too short to convey what the tool does.`,
84
+ serverName: connection.server.name,
85
+ toolName: tool.name,
86
+ category: 'quality',
87
+ confidence: 'medium',
88
+ suggestedFix: {
89
+ description: `Expand "${tool.name}"'s description into at least a short sentence.`,
90
+ },
91
+ });
92
+ }
93
+ }
94
+ }
95
+ catch (err) {
96
+ results.push({
97
+ checkId: 'quality.vague-description',
98
+ severity: 'error',
99
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
100
+ serverName: connection.server.name,
101
+ category: 'quality',
102
+ });
103
+ }
104
+ return results;
105
+ },
106
+ };
@@ -0,0 +1,2 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const qualityToolNamesCheck: Check;