mcp-medic 1.2.2 → 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.
Files changed (38) hide show
  1. package/README.md +82 -8
  2. package/dist/checks/index.d.ts +8 -0
  3. package/dist/checks/index.js +24 -0
  4. package/dist/checks/malformed-schema.js +17 -0
  5. package/dist/checks/protocol-connection-health.d.ts +17 -0
  6. package/dist/checks/protocol-connection-health.js +78 -0
  7. package/dist/checks/quality-prompts.d.ts +8 -0
  8. package/dist/checks/quality-prompts.js +121 -0
  9. package/dist/checks/quality-resources.d.ts +8 -0
  10. package/dist/checks/quality-resources.js +92 -0
  11. package/dist/checks/quality-tool-annotations.d.ts +10 -0
  12. package/dist/checks/quality-tool-annotations.js +63 -0
  13. package/dist/checks/quality-tool-descriptions.d.ts +2 -0
  14. package/dist/checks/quality-tool-descriptions.js +106 -0
  15. package/dist/checks/quality-tool-names.d.ts +35 -0
  16. package/dist/checks/quality-tool-names.js +160 -0
  17. package/dist/checks/quality-tool-output-schema.d.ts +10 -0
  18. package/dist/checks/quality-tool-output-schema.js +76 -0
  19. package/dist/checks/quality-tool-surface.d.ts +13 -0
  20. package/dist/checks/quality-tool-surface.js +99 -0
  21. package/dist/cli.d.ts +3 -1
  22. package/dist/cli.js +35 -3
  23. package/dist/diagnostics.d.ts +11 -0
  24. package/dist/diagnostics.js +28 -0
  25. package/dist/index.d.ts +7 -0
  26. package/dist/index.js +5 -0
  27. package/dist/orchestrator.js +7 -1
  28. package/dist/policy.d.ts +9 -0
  29. package/dist/policy.js +72 -0
  30. package/dist/protocol/connect.js +42 -1
  31. package/dist/protocol/quality-rules.d.ts +46 -0
  32. package/dist/protocol/quality-rules.js +37 -0
  33. package/dist/quality-score.d.ts +76 -0
  34. package/dist/quality-score.js +242 -0
  35. package/dist/report.d.ts +3 -0
  36. package/dist/report.js +54 -0
  37. package/dist/types.d.ts +81 -1
  38. package/package.json +1 -1
@@ -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,35 @@
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[];
35
+ export declare const qualityToolNamesCheck: Check;
@@ -0,0 +1,160 @@
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. */
4
+ const MAX_REASONABLE_NAME_LENGTH = 128;
5
+ /** Conservative, curated list — exact (case-insensitive) matches only, to
6
+ * avoid false-positiving on legitimately short/plain real tool names. */
7
+ const PLACEHOLDER_NAMES = new Set([
8
+ 'tool',
9
+ 'test',
10
+ 'temp',
11
+ 'foo',
12
+ 'bar',
13
+ 'function',
14
+ 'func',
15
+ 'untitled',
16
+ 'new_tool',
17
+ 'newtool',
18
+ 'example',
19
+ 'sample',
20
+ 'todo',
21
+ 'tbd',
22
+ 'xxx',
23
+ ]);
24
+ function hasInvalidCharacters(name) {
25
+ // Whitespace (beyond a single space) and control characters break most
26
+ // client UIs and tool-name-as-identifier assumptions, even though the
27
+ // spec doesn't forbid them outright.
28
+ // eslint-disable-next-line no-control-regex
29
+ return /[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f]/.test(name);
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
+ }
107
+ export const qualityToolNamesCheck = {
108
+ id: 'quality.tool-name',
109
+ description: 'Flags empty, duplicate, overly long, or placeholder-looking tool names; distinguishes protocol-version-defined violations from quality recommendations.',
110
+ run(connection) {
111
+ const results = [];
112
+ try {
113
+ if (!connection.tools || !Array.isArray(connection.tools)) {
114
+ return results;
115
+ }
116
+ const rules = getProtocolQualityRules(connection.protocolVersion?.negotiated).toolName;
117
+ const seen = new Map();
118
+ for (const tool of connection.tools) {
119
+ const name = tool.name;
120
+ seen.set(name, (seen.get(name) ?? 0) + 1);
121
+ for (const finding of evaluateToolName(name, rules)) {
122
+ results.push({
123
+ checkId: 'quality.tool-name',
124
+ severity: finding.severity,
125
+ message: finding.message,
126
+ serverName: connection.server.name,
127
+ toolName: name,
128
+ category: finding.category,
129
+ ...(finding.confidence ? { confidence: finding.confidence } : {}),
130
+ suggestedFix: { description: finding.suggestedFixDescription },
131
+ });
132
+ }
133
+ }
134
+ for (const [name, count] of seen) {
135
+ if (count > 1) {
136
+ results.push({
137
+ checkId: 'quality.tool-name',
138
+ severity: 'error',
139
+ message: `Tool name "${name}" is declared ${count} times — a client cannot reliably invoke a specific one by name.`,
140
+ serverName: connection.server.name,
141
+ toolName: name,
142
+ category: 'quality',
143
+ details: { duplicateCount: count },
144
+ suggestedFix: { description: `Rename duplicate "${name}" tools so every tool name is unique.` },
145
+ });
146
+ }
147
+ }
148
+ }
149
+ catch (err) {
150
+ results.push({
151
+ checkId: 'quality.tool-name',
152
+ severity: 'error',
153
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
154
+ serverName: connection.server.name,
155
+ category: 'quality',
156
+ });
157
+ }
158
+ return results;
159
+ },
160
+ };
@@ -0,0 +1,10 @@
1
+ import type { Check } from '../types.js';
2
+ /**
3
+ * `outputSchema` is optional per the MCP spec (2025-06-18+) — this check
4
+ * never flags its absence. It only inspects an `outputSchema` that IS
5
+ * present, and only for basic structural validity (same shape rules as
6
+ * `inputSchema`: must be an object, should declare "object" as its type).
7
+ * Malformed output schemas are a quality warning, not a protocol error,
8
+ * since a client can simply ignore an outputSchema it can't parse.
9
+ */
10
+ export declare const qualityToolOutputSchemaCheck: Check;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `outputSchema` is optional per the MCP spec (2025-06-18+) — this check
3
+ * never flags its absence. It only inspects an `outputSchema` that IS
4
+ * present, and only for basic structural validity (same shape rules as
5
+ * `inputSchema`: must be an object, should declare "object" as its type).
6
+ * Malformed output schemas are a quality warning, not a protocol error,
7
+ * since a client can simply ignore an outputSchema it can't parse.
8
+ */
9
+ export const qualityToolOutputSchemaCheck = {
10
+ id: 'quality.output-schema',
11
+ description: 'Flags tool outputSchemas that are present but structurally malformed. Never flags a missing outputSchema — it is optional.',
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 schema = tool.outputSchema;
20
+ if (schema === undefined)
21
+ continue; // optional; absence is fine
22
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
23
+ const actualType = schema === null ? 'null' : Array.isArray(schema) ? 'array' : typeof schema;
24
+ results.push({
25
+ checkId: 'quality.output-schema',
26
+ severity: 'warning',
27
+ message: `Tool "${tool.name}" declares an outputSchema, but it is ${actualType} instead of a JSON Schema object.`,
28
+ serverName: connection.server.name,
29
+ toolName: tool.name,
30
+ category: 'quality',
31
+ details: { actualType },
32
+ suggestedFix: {
33
+ description: 'Replace outputSchema with a valid JSON Schema object, or remove it if not needed.',
34
+ },
35
+ });
36
+ continue;
37
+ }
38
+ const schemaObj = schema;
39
+ const hasType = typeof schemaObj.type === 'string' || Array.isArray(schemaObj.type);
40
+ const hasCombinatorOrRef = Boolean(schemaObj.$ref) || Boolean(schemaObj.oneOf) || Boolean(schemaObj.anyOf) || Boolean(schemaObj.allOf);
41
+ if (!hasType && !hasCombinatorOrRef) {
42
+ results.push({
43
+ checkId: 'quality.output-schema',
44
+ severity: 'warning',
45
+ message: `Tool "${tool.name}" outputSchema is missing a "type" or combinator field.`,
46
+ serverName: connection.server.name,
47
+ toolName: tool.name,
48
+ category: 'quality',
49
+ suggestedFix: { description: 'Add `"type": "object"` to the outputSchema.' },
50
+ });
51
+ }
52
+ else if (typeof schemaObj.type === 'string' && schemaObj.type !== 'object') {
53
+ results.push({
54
+ checkId: 'quality.output-schema',
55
+ severity: 'warning',
56
+ message: `Tool "${tool.name}" outputSchema declares type "${schemaObj.type}" instead of "object".`,
57
+ serverName: connection.server.name,
58
+ toolName: tool.name,
59
+ category: 'quality',
60
+ suggestedFix: { description: 'Change outputSchema\'s top-level "type" to "object".' },
61
+ });
62
+ }
63
+ }
64
+ }
65
+ catch (err) {
66
+ results.push({
67
+ checkId: 'quality.output-schema',
68
+ severity: 'error',
69
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
70
+ serverName: connection.server.name,
71
+ category: 'quality',
72
+ });
73
+ }
74
+ return results;
75
+ },
76
+ };
@@ -0,0 +1,13 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const DEFAULT_MAX_TOOLS_WARNING_THRESHOLD = 100;
3
+ /**
4
+ * Factory rather than a static Check, so the tool-count threshold can be
5
+ * overridden by policy (see `policy.ts`'s `quality.maxTools`) without
6
+ * needing a second, parallel check — same pattern `createPolicyChecks`
7
+ * already uses for configurable org rules.
8
+ */
9
+ export declare function createToolSurfaceCheck(options?: {
10
+ maxTools?: number;
11
+ }): Check;
12
+ /** Default instance (threshold 100) for the standard `allChecks` list. */
13
+ export declare const qualityToolSurfaceCheck: Check;
@@ -0,0 +1,99 @@
1
+ export const DEFAULT_MAX_TOOLS_WARNING_THRESHOLD = 100;
2
+ function normalizeForSimilarity(name) {
3
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '');
4
+ }
5
+ /**
6
+ * Factory rather than a static Check, so the tool-count threshold can be
7
+ * overridden by policy (see `policy.ts`'s `quality.maxTools`) without
8
+ * needing a second, parallel check — same pattern `createPolicyChecks`
9
+ * already uses for configurable org rules.
10
+ */
11
+ export function createToolSurfaceCheck(options = {}) {
12
+ const maxTools = options.maxTools ?? DEFAULT_MAX_TOOLS_WARNING_THRESHOLD;
13
+ return {
14
+ id: 'quality.tool-surface',
15
+ description: `Flags excessive tool counts (default warning threshold: ${DEFAULT_MAX_TOOLS_WARNING_THRESHOLD}) and near-duplicate tool names/descriptions.`,
16
+ run(connection) {
17
+ const results = [];
18
+ try {
19
+ const tools = connection.tools;
20
+ if (!tools || !Array.isArray(tools) || tools.length === 0) {
21
+ return results;
22
+ }
23
+ if (tools.length > maxTools) {
24
+ results.push({
25
+ checkId: 'quality.tool-surface',
26
+ severity: 'warning',
27
+ message: `Server exposes ${tools.length} tools, exceeding the ${maxTools}-tool threshold. Large tool surfaces can increase agent/tool-selection complexity.`,
28
+ serverName: connection.server.name,
29
+ category: 'quality',
30
+ details: { toolCount: tools.length, threshold: maxTools },
31
+ suggestedFix: {
32
+ description: 'Consider splitting this server, consolidating overlapping tools, or raising the policy threshold if this is intentional.',
33
+ },
34
+ });
35
+ }
36
+ // Near-duplicate names: same normalized form but not byte-identical.
37
+ // (Exact duplicates are quality.tool-name's job.)
38
+ const byNormalized = new Map();
39
+ for (const tool of tools) {
40
+ const key = normalizeForSimilarity(tool.name);
41
+ if (!key)
42
+ continue;
43
+ const group = byNormalized.get(key) ?? [];
44
+ group.push(tool.name);
45
+ byNormalized.set(key, group);
46
+ }
47
+ for (const group of byNormalized.values()) {
48
+ const distinct = [...new Set(group)];
49
+ if (distinct.length > 1) {
50
+ results.push({
51
+ checkId: 'quality.tool-surface',
52
+ severity: 'warning',
53
+ message: `Tools ${distinct.map((n) => `"${n}"`).join(', ')} have near-identical names — this can confuse tool selection.`,
54
+ serverName: connection.server.name,
55
+ category: 'quality',
56
+ details: { similarNames: distinct },
57
+ suggestedFix: { description: 'Rename these tools to be clearly distinct from one another.' },
58
+ });
59
+ }
60
+ }
61
+ // Repeated, non-trivial descriptions shared by 3+ tools.
62
+ const byDescription = new Map();
63
+ for (const tool of tools) {
64
+ const desc = tool.description?.trim();
65
+ if (!desc || desc.length < 15)
66
+ continue; // trivial/short strings collide legitimately
67
+ const names = byDescription.get(desc) ?? [];
68
+ names.push(tool.name);
69
+ byDescription.set(desc, names);
70
+ }
71
+ for (const [desc, names] of byDescription) {
72
+ if (names.length >= 3) {
73
+ results.push({
74
+ checkId: 'quality.tool-surface',
75
+ severity: 'warning',
76
+ message: `${names.length} tools (${names.map((n) => `"${n}"`).join(', ')}) share the exact same description — each tool should describe its own specific behavior.`,
77
+ serverName: connection.server.name,
78
+ category: 'quality',
79
+ details: { sharedDescription: desc, toolNames: names },
80
+ suggestedFix: { description: 'Write a distinct, specific description for each of these tools.' },
81
+ });
82
+ }
83
+ }
84
+ }
85
+ catch (err) {
86
+ results.push({
87
+ checkId: 'quality.tool-surface',
88
+ severity: 'error',
89
+ message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
90
+ serverName: connection.server.name,
91
+ category: 'quality',
92
+ });
93
+ }
94
+ return results;
95
+ },
96
+ };
97
+ }
98
+ /** Default instance (threshold 100) for the standard `allChecks` list. */
99
+ export const qualityToolSurfaceCheck = createToolSurfaceCheck();
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  export interface ParsedArgs {
3
- command: 'check' | 'watch' | 'check-all' | 'diff' | 'fix' | 'help' | 'version';
3
+ command: 'check' | 'watch' | 'check-all' | 'diff' | 'fix' | 'score' | 'help' | 'version';
4
4
  configPath?: string;
5
5
  configPathB?: string;
6
6
  globPattern?: string;
@@ -18,6 +18,8 @@ export interface ParsedArgs {
18
18
  failOn: 'error' | 'warning';
19
19
  checkFilter?: string;
20
20
  dryRun: boolean;
21
+ /** Include the MCP quality score section in the report (`--score`, or implied by the `score` command). */
22
+ showScore: boolean;
21
23
  /** "auto" (default) or an explicit MCP protocolVersion string, e.g. "2025-06-18". */
22
24
  protocolVersion: string;
23
25
  }