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.
@@ -67,6 +67,23 @@ function validateResponse(response, expectedId) {
67
67
  }
68
68
  return response.result;
69
69
  }
70
+ function normalizeToolAnnotations(value) {
71
+ if (!value || typeof value !== 'object' || Array.isArray(value))
72
+ return undefined;
73
+ const record = value;
74
+ const annotations = {};
75
+ if (typeof record.title === 'string')
76
+ annotations.title = record.title;
77
+ if (typeof record.readOnlyHint === 'boolean')
78
+ annotations.readOnlyHint = record.readOnlyHint;
79
+ if (typeof record.destructiveHint === 'boolean')
80
+ annotations.destructiveHint = record.destructiveHint;
81
+ if (typeof record.idempotentHint === 'boolean')
82
+ annotations.idempotentHint = record.idempotentHint;
83
+ if (typeof record.openWorldHint === 'boolean')
84
+ annotations.openWorldHint = record.openWorldHint;
85
+ return annotations;
86
+ }
70
87
  function normalizeTools(result) {
71
88
  if (!Array.isArray(result.tools))
72
89
  throw new Error('tools/list response has no tools array');
@@ -77,8 +94,11 @@ function normalizeTools(result) {
77
94
  const value = tool;
78
95
  return {
79
96
  name: value.name,
97
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
80
98
  ...(typeof value.description === 'string' ? { description: value.description } : {}),
81
99
  inputSchema: value.inputSchema,
100
+ ...(value.outputSchema !== undefined ? { outputSchema: value.outputSchema } : {}),
101
+ ...(normalizeToolAnnotations(value.annotations) ? { annotations: normalizeToolAnnotations(value.annotations) } : {}),
82
102
  };
83
103
  });
84
104
  }
@@ -93,11 +113,29 @@ function normalizeResources(result) {
93
113
  return {
94
114
  uri: value.uri,
95
115
  ...(typeof value.name === 'string' ? { name: value.name } : {}),
116
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
96
117
  ...(typeof value.description === 'string' ? { description: value.description } : {}),
97
118
  ...(typeof value.mimeType === 'string' ? { mimeType: value.mimeType } : {}),
119
+ ...(typeof value.size === 'number' ? { size: value.size } : {}),
98
120
  };
99
121
  });
100
122
  }
123
+ function normalizePromptArgument(value) {
124
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
125
+ return {};
126
+ }
127
+ const record = value;
128
+ const arg = {};
129
+ if (typeof record.name === 'string')
130
+ arg.name = record.name;
131
+ if (typeof record.title === 'string')
132
+ arg.title = record.title;
133
+ if (typeof record.description === 'string')
134
+ arg.description = record.description;
135
+ if (typeof record.required === 'boolean')
136
+ arg.required = record.required;
137
+ return arg;
138
+ }
101
139
  function normalizePrompts(result) {
102
140
  if (!Array.isArray(result.prompts))
103
141
  throw new Error('prompts/list response has no prompts array');
@@ -108,8 +146,11 @@ function normalizePrompts(result) {
108
146
  const value = prompt;
109
147
  return {
110
148
  name: value.name,
149
+ ...(typeof value.title === 'string' ? { title: value.title } : {}),
111
150
  ...(typeof value.description === 'string' ? { description: value.description } : {}),
112
- ...(Array.isArray(value.arguments) ? { arguments: value.arguments } : {}),
151
+ ...(Array.isArray(value.arguments)
152
+ ? { arguments: value.arguments.map(normalizePromptArgument) }
153
+ : {}),
113
154
  };
114
155
  });
115
156
  }
@@ -0,0 +1,33 @@
1
+ import type { MCPConnection, DiagnosticResult, RunReport, QualityDimension, QualityDeduction, QualityScoreBreakdown, ReportQualityScore } from './types.js';
2
+ /**
3
+ * Deterministic MCP quality score. No LLM, no randomness, no network
4
+ * calls beyond the MCP inspection `connect()` already performed — the same
5
+ * report always produces the same score.
6
+ *
7
+ * Design: diagnostics -> normalized per-checkId deductions -> capped
8
+ * per-dimension score -> weighted overall score. This keeps the score
9
+ * fully derived from (and consistent with) the diagnostics that are also
10
+ * shown in the report, rather than an independent "second opinion" — see
11
+ * .agent-room/DECISIONS.md for the full rationale.
12
+ *
13
+ * (Result types — QualityDimension, QualityDeduction, QualityScoreBreakdown,
14
+ * ReportQualityScore — live in src/types.ts alongside RunReport, since
15
+ * RunReport.quality is one of them; this module just implements the math.)
16
+ */
17
+ export type { QualityDimension, QualityDeduction, QualityScoreBreakdown, ReportQualityScore };
18
+ export declare const QUALITY_DIMENSIONS: readonly QualityDimension[];
19
+ /** Suggested weights from the product spec; documented here since every
20
+ * point deduction must be explainable in terms of these. */
21
+ export declare const QUALITY_DIMENSION_WEIGHTS: Record<QualityDimension, number>;
22
+ /**
23
+ * Computes a quality score for one connection. Returns `undefined` for a
24
+ * connection that never connected — there's nothing meaningful to score
25
+ * (no tools/schema/capabilities were ever observed).
26
+ */
27
+ export declare function computeConnectionQualityScore(connection: MCPConnection, diagnostics: DiagnosticResult[]): QualityScoreBreakdown | undefined;
28
+ /**
29
+ * Aggregate score across every connected server in a report (simple mean —
30
+ * deliberately not weighted by tool count, so one huge server can't drown
31
+ * out the rest of a fleet). Returns `undefined` if no server connected.
32
+ */
33
+ export declare function computeReportQualityScore(report: RunReport): ReportQualityScore | undefined;
@@ -0,0 +1,163 @@
1
+ import { categoryOf } from './diagnostics.js';
2
+ export const QUALITY_DIMENSIONS = [
3
+ 'protocol',
4
+ 'schema',
5
+ 'usability',
6
+ 'security',
7
+ 'reliability',
8
+ ];
9
+ /** Suggested weights from the product spec; documented here since every
10
+ * point deduction must be explainable in terms of these. */
11
+ export const QUALITY_DIMENSION_WEIGHTS = {
12
+ protocol: 0.25,
13
+ schema: 0.2,
14
+ usability: 0.2,
15
+ security: 0.2,
16
+ reliability: 0.15,
17
+ };
18
+ // Deduction weights: chosen so a handful of real problems meaningfully move
19
+ // the score, while a single noisy checkId can never wipe out a whole
20
+ // dimension on its own (the per-checkId cap) — this directly implements
21
+ // "avoid double-counting" / "sensible caps" from the product spec: e.g. 20
22
+ // tools sharing one description problem cost at most 15 points total, not 60.
23
+ const SEVERITY_POINTS = { error: 8, warning: 3, info: 1 };
24
+ const SEVERITY_CAP_PER_CHECK = { error: 25, warning: 15, info: 5 };
25
+ function categoryToDimension(category) {
26
+ switch (category) {
27
+ case 'protocol':
28
+ return 'protocol';
29
+ case 'schema':
30
+ return 'schema';
31
+ case 'security':
32
+ return 'security';
33
+ case 'reliability':
34
+ return 'reliability';
35
+ // 'usability', 'quality' (the broad bucket most tool/resource/prompt
36
+ // quality checks use), and 'configuration' (policy.* checks) all land
37
+ // under the "Agent usability" dimension from the product spec.
38
+ case 'usability':
39
+ case 'quality':
40
+ case 'configuration':
41
+ default:
42
+ return 'usability';
43
+ }
44
+ }
45
+ function describeDeduction(checkId, severity, count) {
46
+ const plural = count === 1 ? '' : 's';
47
+ return `${count} ${severity}${plural} from "${checkId}"`;
48
+ }
49
+ function deductionsFromDiagnostics(dimension, diagnostics) {
50
+ const grouped = new Map();
51
+ for (const d of diagnostics) {
52
+ if (categoryToDimension(categoryOf(d)) !== dimension)
53
+ continue;
54
+ const key = `${d.checkId}|${d.severity}`;
55
+ const entry = grouped.get(key) ?? { checkId: d.checkId, severity: d.severity, count: 0 };
56
+ entry.count += 1;
57
+ grouped.set(key, entry);
58
+ }
59
+ const deductions = [];
60
+ for (const { checkId, severity, count } of grouped.values()) {
61
+ const points = Math.min(count * SEVERITY_POINTS[severity], SEVERITY_CAP_PER_CHECK[severity]);
62
+ deductions.push({ dimension, checkId, severity, count, points, description: describeDeduction(checkId, severity, count) });
63
+ }
64
+ return deductions;
65
+ }
66
+ /** Protocol-dimension facts that live on `MCPConnection` rather than as
67
+ * diagnostics (capability negotiation isn't produced by a `Check`). */
68
+ function protocolConnectionDeductions(connection) {
69
+ const deductions = [];
70
+ if (connection.capabilityErrors?.resources) {
71
+ deductions.push({
72
+ dimension: 'protocol',
73
+ checkId: 'protocol.capability-error',
74
+ severity: 'warning',
75
+ count: 1,
76
+ points: 15,
77
+ description: 'resources/list failed despite being declared as a capability',
78
+ });
79
+ }
80
+ if (connection.capabilityErrors?.prompts) {
81
+ deductions.push({
82
+ dimension: 'protocol',
83
+ checkId: 'protocol.capability-error',
84
+ severity: 'warning',
85
+ count: 1,
86
+ points: 15,
87
+ description: 'prompts/list failed despite being declared as a capability',
88
+ });
89
+ }
90
+ if (connection.protocolVersion &&
91
+ connection.protocolVersion.negotiated &&
92
+ connection.protocolVersion.negotiated !== connection.protocolVersion.requested) {
93
+ deductions.push({
94
+ dimension: 'protocol',
95
+ checkId: 'protocol.version-downgrade',
96
+ severity: 'info',
97
+ count: 1,
98
+ points: 5,
99
+ description: `server negotiated ${connection.protocolVersion.negotiated} instead of the requested ${connection.protocolVersion.requested}`,
100
+ });
101
+ }
102
+ if (!connection.serverInfo?.name) {
103
+ deductions.push({
104
+ dimension: 'protocol',
105
+ checkId: 'protocol.missing-server-info',
106
+ severity: 'info',
107
+ count: 1,
108
+ points: 5,
109
+ description: 'server did not report its name/version in serverInfo',
110
+ });
111
+ }
112
+ return deductions;
113
+ }
114
+ function dimensionScore(deductions) {
115
+ const totalPoints = deductions.reduce((sum, d) => sum + d.points, 0);
116
+ return Math.max(0, Math.min(100, Math.round(100 - totalPoints)));
117
+ }
118
+ /**
119
+ * Computes a quality score for one connection. Returns `undefined` for a
120
+ * connection that never connected — there's nothing meaningful to score
121
+ * (no tools/schema/capabilities were ever observed).
122
+ */
123
+ export function computeConnectionQualityScore(connection, diagnostics) {
124
+ if (connection.status !== 'connected')
125
+ return undefined;
126
+ const relevant = diagnostics.filter((d) => d.serverName === connection.server.name);
127
+ const allDeductions = [
128
+ ...protocolConnectionDeductions(connection),
129
+ ...QUALITY_DIMENSIONS.flatMap((dim) => deductionsFromDiagnostics(dim, relevant)),
130
+ ];
131
+ const dimensions = Object.fromEntries(QUALITY_DIMENSIONS.map((dim) => [dim, dimensionScore(allDeductions.filter((d) => d.dimension === dim))]));
132
+ const overall = Math.round(QUALITY_DIMENSIONS.reduce((sum, dim) => sum + dimensions[dim] * QUALITY_DIMENSION_WEIGHTS[dim], 0));
133
+ return {
134
+ overall,
135
+ dimensions,
136
+ deductions: allDeductions.filter((d) => d.points > 0).sort((a, b) => b.points - a.points),
137
+ };
138
+ }
139
+ /**
140
+ * Aggregate score across every connected server in a report (simple mean —
141
+ * deliberately not weighted by tool count, so one huge server can't drown
142
+ * out the rest of a fleet). Returns `undefined` if no server connected.
143
+ */
144
+ export function computeReportQualityScore(report) {
145
+ const perServer = {};
146
+ for (const connection of report.connections) {
147
+ const breakdown = computeConnectionQualityScore(connection, report.diagnostics);
148
+ if (breakdown)
149
+ perServer[connection.server.name] = breakdown;
150
+ }
151
+ const names = Object.keys(perServer);
152
+ if (names.length === 0)
153
+ return undefined;
154
+ const dimensions = Object.fromEntries(QUALITY_DIMENSIONS.map((dim) => [
155
+ dim,
156
+ Math.round(names.reduce((sum, n) => sum + perServer[n].dimensions[dim], 0) / names.length),
157
+ ]));
158
+ const overall = Math.round(names.reduce((sum, n) => sum + perServer[n].overall, 0) / names.length);
159
+ const deductions = names
160
+ .flatMap((n) => perServer[n].deductions)
161
+ .sort((a, b) => b.points - a.points);
162
+ return { overall, dimensions, deductions, perServer };
163
+ }
package/dist/report.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { RunReport } from './types.js';
2
2
  export interface FormatReportOptions {
3
3
  showFixes?: boolean;
4
+ /** Include the MCP quality score section (Phase 7). Off by default so a
5
+ * plain `check` report stays short; `--score`/`score` turn it on. */
6
+ showScore?: boolean;
4
7
  }
5
8
  /** Human-readable plain text report formatter. */
6
9
  export declare function formatReportHuman(report: RunReport, options?: FormatReportOptions): string;
package/dist/report.js CHANGED
@@ -1,3 +1,26 @@
1
+ const DIMENSION_LABELS = {
2
+ protocol: 'Protocol',
3
+ schema: 'Schema',
4
+ usability: 'Agent usability',
5
+ security: 'Security',
6
+ reliability: 'Reliability',
7
+ };
8
+ function formatScoreBlock(label, score) {
9
+ const lines = [];
10
+ lines.push(label);
11
+ for (const [dim, value] of Object.entries(score.dimensions)) {
12
+ const name = (DIMENSION_LABELS[dim] ?? dim).padEnd(17, ' ');
13
+ lines.push(` ${name} ${String(value).padStart(3, ' ')}/100`);
14
+ }
15
+ lines.push(` ${'MCP QUALITY SCORE'.padEnd(17, ' ')} ${String(score.overall).padStart(3, ' ')}/100`);
16
+ if (score.deductions.length > 0) {
17
+ lines.push(' Deductions:');
18
+ for (const d of score.deductions) {
19
+ lines.push(` -${d.points} ${d.description}`);
20
+ }
21
+ }
22
+ return lines;
23
+ }
1
24
  /** Human-readable plain text report formatter. */
2
25
  export function formatReportHuman(report, options = {}) {
3
26
  const lines = [];
@@ -30,6 +53,15 @@ export function formatReportHuman(report, options = {}) {
30
53
  if (conn.error) {
31
54
  lines.push(` ${conn.error.stage}: ${conn.error.message}`);
32
55
  }
56
+ const perServerScore = options.showScore ? report.quality?.perServer[conn.server.name] : undefined;
57
+ if (perServerScore) {
58
+ lines.push('');
59
+ lines.push(...formatScoreBlock('QUALITY', perServerScore));
60
+ }
61
+ }
62
+ if (options.showScore && report.quality && Object.keys(report.quality.perServer).length > 1) {
63
+ lines.push('');
64
+ lines.push(...formatScoreBlock('OVERALL QUALITY (all servers)', report.quality));
33
65
  }
34
66
  if (report.diagnostics.length > 0) {
35
67
  lines.push('');
package/dist/types.d.ts CHANGED
@@ -14,21 +14,48 @@ export interface MCPConfig {
14
14
  servers: MCPServerConfig[];
15
15
  sourcePath?: string;
16
16
  }
17
+ /** Per the MCP spec's `ToolAnnotations`: optional client *hints*, not
18
+ * authoritative security guarantees — a server can lie about any of these. */
19
+ export interface MCPToolAnnotations {
20
+ title?: string;
21
+ readOnlyHint?: boolean;
22
+ destructiveHint?: boolean;
23
+ idempotentHint?: boolean;
24
+ openWorldHint?: boolean;
25
+ }
17
26
  export interface MCPToolDefinition {
18
27
  name: string;
28
+ title?: string;
19
29
  description?: string;
20
30
  inputSchema: unknown;
31
+ /** Optional per spec; same shape as `inputSchema` when present. */
32
+ outputSchema?: unknown;
33
+ annotations?: MCPToolAnnotations;
21
34
  }
22
35
  export interface MCPResourceDefinition {
23
36
  uri: string;
37
+ /** Required by the MCP spec's `Resource` (extends `BaseMetadata`) — kept
38
+ * optional here because mcp-medic reports a missing name as a diagnostic
39
+ * rather than failing the whole `resources/list` response over it. */
24
40
  name?: string;
41
+ title?: string;
25
42
  description?: string;
26
43
  mimeType?: string;
44
+ size?: number;
45
+ }
46
+ export interface MCPPromptArgument {
47
+ /** Required by the MCP spec's `PromptArgument` (extends `BaseMetadata`) —
48
+ * kept optional here for the same reason as `MCPResourceDefinition.name`. */
49
+ name?: string;
50
+ title?: string;
51
+ description?: string;
52
+ required?: boolean;
27
53
  }
28
54
  export interface MCPPromptDefinition {
29
55
  name: string;
56
+ title?: string;
30
57
  description?: string;
31
- arguments?: unknown[];
58
+ arguments?: MCPPromptArgument[];
32
59
  }
33
60
  export interface ProtocolVersionInfo {
34
61
  /** The protocolVersion this client sent in `initialize`. */
@@ -68,6 +95,10 @@ export interface MCPConnection {
68
95
  latencyMs?: number;
69
96
  }
70
97
  export type Severity = 'error' | 'warning' | 'info';
98
+ /** Stable diagnostic taxonomy. Optional and additive — existing checks that
99
+ * don't set this are categorized by `inferDiagnosticCategory()` (src/diagnostics.ts)
100
+ * from their `checkId` prefix, so nothing that already works needs to change. */
101
+ export type DiagnosticCategory = 'protocol' | 'schema' | 'quality' | 'security' | 'reliability' | 'usability' | 'configuration';
71
102
  export interface SuggestedFix {
72
103
  description: string;
73
104
  patch?: unknown;
@@ -80,6 +111,12 @@ export interface DiagnosticResult {
80
111
  toolName?: string;
81
112
  details?: unknown;
82
113
  suggestedFix?: SuggestedFix;
114
+ /** Optional; see `DiagnosticCategory`. */
115
+ category?: DiagnosticCategory;
116
+ /** Optional: how confident the check is that this is a real problem (vs. a heuristic guess). */
117
+ confidence?: 'low' | 'medium' | 'high';
118
+ /** Optional: link to a doc explaining the rule this diagnostic enforces. */
119
+ documentationUrl?: string;
83
120
  }
84
121
  export interface Check {
85
122
  id: string;
@@ -94,6 +131,28 @@ export interface RunOptions {
94
131
  /** "auto" (default) requests the newest protocol version this client supports; an explicit version string requests that version instead. */
95
132
  protocolVersion?: string;
96
133
  }
134
+ /** The five scored dimensions of MCP quality (see src/quality-score.ts for the scoring model). */
135
+ export type QualityDimension = 'protocol' | 'schema' | 'usability' | 'security' | 'reliability';
136
+ export interface QualityDeduction {
137
+ dimension: QualityDimension;
138
+ checkId: string;
139
+ severity: Severity;
140
+ /** How many diagnostics from this checkId (at this severity) contributed. */
141
+ count: number;
142
+ /** Points actually deducted, after the per-checkId cap. */
143
+ points: number;
144
+ description: string;
145
+ }
146
+ export interface QualityScoreBreakdown {
147
+ overall: number;
148
+ dimensions: Record<QualityDimension, number>;
149
+ /** Only deductions with points > 0, most-costly first — every deduction here explains itself. */
150
+ deductions: QualityDeduction[];
151
+ }
152
+ export interface ReportQualityScore extends QualityScoreBreakdown {
153
+ /** Per-connected-server breakdown; a server that never connected has no entry (nothing to score). */
154
+ perServer: Record<string, QualityScoreBreakdown>;
155
+ }
97
156
  export interface RunReport {
98
157
  configSource?: string;
99
158
  connections: MCPConnection[];
@@ -105,4 +164,7 @@ export interface RunReport {
105
164
  errors: number;
106
165
  warnings: number;
107
166
  };
167
+ /** Deterministic quality score derived from `diagnostics`/`connections` — see src/quality-score.ts.
168
+ * `undefined` when no server connected (nothing to score). */
169
+ quality?: ReportQualityScore;
108
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-medic",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",