Skip to content

Commit 5a3bb63

Browse files
KryptosAIclaude
andauthored
feat: health score, conformance checks, CI reporters, and badge generation (#60)
Add "Lighthouse for MCP" scoring system with five weighted dimensions: - Protocol compliance (conformance check) - Schema quality (tool/prompt/resource description quality) - Security (existing security check) - Reliability (tools/prompts/resources/invoke checks) - Performance (connection + operation latency) New features: - `score` CLI command: visual health score with bar chart (0-100, A-F grade) - `badge` CLI command: generate shields.io-style SVG badge for READMEs - `--format junit`: JUnit XML output for GitHub Actions test annotations - `--format sarif`: SARIF v2.1.0 output for GitHub Security tab - `score_server` MCP tool: health scoring for AI agents - Conformance check: validates capabilities, endpoint responses, error handling - Schema quality check: tool descriptions, input schemas, property docs - Performance metrics: connect time, per-check latency, percentiles - Health score displayed in terminal and markdown reporters Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1cfe96 commit 5a3bb63

21 files changed

+1460
-11
lines changed

src/badge.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { HealthGrade } from "./types.js";
2+
3+
const GRADE_COLORS: Record<HealthGrade, string> = {
4+
A: "#4c1",
5+
B: "#97ca00",
6+
C: "#dfb317",
7+
D: "#fe7d37",
8+
F: "#e05d44",
9+
};
10+
11+
export interface BadgeOptions {
12+
label?: string;
13+
score: number;
14+
grade: HealthGrade;
15+
}
16+
17+
export function generateBadgeSvg(options: BadgeOptions): string {
18+
const label = options.label ?? "MCP Health";
19+
const value = `${options.score}/100`;
20+
const color = GRADE_COLORS[options.grade];
21+
22+
// Approximate text widths (7px per character for the 11px Verdana used by shields.io)
23+
const labelWidth = label.length * 7 + 10;
24+
const valueWidth = value.length * 7 + 10;
25+
const totalWidth = labelWidth + valueWidth;
26+
27+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
28+
<title>${label}: ${value}</title>
29+
<linearGradient id="s" x2="0" y2="100%">
30+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
31+
<stop offset="1" stop-opacity=".1"/>
32+
</linearGradient>
33+
<clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath>
34+
<g clip-path="url(#r)">
35+
<rect width="${labelWidth}" height="20" fill="#555"/>
36+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
37+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
38+
</g>
39+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
40+
<text aria-hidden="true" x="${labelWidth * 5}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">${label}</text>
41+
<text x="${labelWidth * 5}" y="140" transform="scale(.1)">${label}</text>
42+
<text aria-hidden="true" x="${(labelWidth + valueWidth / 2) * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">${value}</text>
43+
<text x="${(labelWidth + valueWidth / 2) * 10}" y="140" transform="scale(.1)">${value}</text>
44+
</g>
45+
</svg>`;
46+
}

src/checks/conformance.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { performance } from "node:perf_hooks";
2+
3+
import { isCapabilityAdvertised, makeCheckResult, type CheckContext, type ObservedCheck } from "./base.js";
4+
import type { EvidenceSummary } from "../types.js";
5+
6+
interface ConformanceFinding {
7+
rule: string;
8+
passed: boolean;
9+
detail: string;
10+
}
11+
12+
function checkCapabilitiesPresent(context: CheckContext): ConformanceFinding {
13+
const caps = context.serverCapabilities;
14+
if (caps === undefined) {
15+
return { rule: "capabilities-present", passed: false, detail: "Server did not return capabilities during initialization." };
16+
}
17+
return { rule: "capabilities-present", passed: true, detail: "Server returned capabilities object." };
18+
}
19+
20+
function checkServerInfo(context: CheckContext): ConformanceFinding {
21+
const caps = context.serverCapabilities;
22+
if (!caps) {
23+
return { rule: "server-info", passed: false, detail: "Cannot verify server info — no capabilities returned." };
24+
}
25+
return { rule: "server-info", passed: true, detail: "Server provided initialization info." };
26+
}
27+
28+
async function checkToolsEndpoint(context: CheckContext): Promise<ConformanceFinding> {
29+
if (!isCapabilityAdvertised(context.serverCapabilities, "tools")) {
30+
return { rule: "tools-capability-match", passed: true, detail: "Tools not advertised — endpoint check skipped." };
31+
}
32+
try {
33+
const resp = await context.client.listTools(undefined, { timeout: context.timeoutMs });
34+
if (!Array.isArray(resp.tools)) {
35+
return { rule: "tools-capability-match", passed: false, detail: "tools/list did not return an array of tools." };
36+
}
37+
return { rule: "tools-capability-match", passed: true, detail: `tools/list returned ${resp.tools.length} tool(s).` };
38+
} catch (error) {
39+
const msg = error instanceof Error ? error.message : String(error);
40+
return { rule: "tools-capability-match", passed: false, detail: `Advertised tools but tools/list failed: ${msg}` };
41+
}
42+
}
43+
44+
async function checkPromptsEndpoint(context: CheckContext): Promise<ConformanceFinding> {
45+
if (!isCapabilityAdvertised(context.serverCapabilities, "prompts")) {
46+
return { rule: "prompts-capability-match", passed: true, detail: "Prompts not advertised — endpoint check skipped." };
47+
}
48+
try {
49+
const resp = await context.client.listPrompts(undefined, { timeout: context.timeoutMs });
50+
if (!Array.isArray(resp.prompts)) {
51+
return { rule: "prompts-capability-match", passed: false, detail: "prompts/list did not return an array of prompts." };
52+
}
53+
return { rule: "prompts-capability-match", passed: true, detail: `prompts/list returned ${resp.prompts.length} prompt(s).` };
54+
} catch (error) {
55+
const msg = error instanceof Error ? error.message : String(error);
56+
return { rule: "prompts-capability-match", passed: false, detail: `Advertised prompts but prompts/list failed: ${msg}` };
57+
}
58+
}
59+
60+
async function checkResourcesEndpoint(context: CheckContext): Promise<ConformanceFinding> {
61+
if (!isCapabilityAdvertised(context.serverCapabilities, "resources")) {
62+
return { rule: "resources-capability-match", passed: true, detail: "Resources not advertised — endpoint check skipped." };
63+
}
64+
try {
65+
const resp = await context.client.listResources(undefined, { timeout: context.timeoutMs });
66+
if (!Array.isArray(resp.resources)) {
67+
return { rule: "resources-capability-match", passed: false, detail: "resources/list did not return an array of resources." };
68+
}
69+
return { rule: "resources-capability-match", passed: true, detail: `resources/list returned ${resp.resources.length} resource(s).` };
70+
} catch (error) {
71+
const msg = error instanceof Error ? error.message : String(error);
72+
return { rule: "resources-capability-match", passed: false, detail: `Advertised resources but resources/list failed: ${msg}` };
73+
}
74+
}
75+
76+
async function checkToolResponseContent(context: CheckContext): Promise<ConformanceFinding> {
77+
if (!isCapabilityAdvertised(context.serverCapabilities, "tools")) {
78+
return { rule: "tool-response-content", passed: true, detail: "No tools — content check skipped." };
79+
}
80+
try {
81+
const { tools } = await context.client.listTools(undefined, { timeout: context.timeoutMs });
82+
// Find a tool safe to invoke (no required params)
83+
const safeTool = tools.find(t => {
84+
const schema = t.inputSchema as Record<string, unknown> | undefined;
85+
const required = schema?.["required"] as string[] | undefined;
86+
return !required || required.length === 0;
87+
});
88+
if (!safeTool) {
89+
return { rule: "tool-response-content", passed: true, detail: "No safe tool to invoke — content validation skipped." };
90+
}
91+
const result = await context.client.callTool({ name: safeTool.name, arguments: {} }, undefined, { timeout: context.timeoutMs });
92+
if (!Array.isArray(result.content)) {
93+
return { rule: "tool-response-content", passed: false, detail: `Tool "${safeTool.name}" response.content is not an array.` };
94+
}
95+
for (const item of result.content) {
96+
const typed = item as Record<string, unknown>;
97+
if (typeof typed["type"] !== "string") {
98+
return { rule: "tool-response-content", passed: false, detail: `Tool "${safeTool.name}" response content item missing 'type' field.` };
99+
}
100+
}
101+
return { rule: "tool-response-content", passed: true, detail: `Tool "${safeTool.name}" response has valid content array.` };
102+
} catch (error) {
103+
const msg = error instanceof Error ? error.message : String(error);
104+
return { rule: "tool-response-content", passed: false, detail: `Tool response content check failed: ${msg}` };
105+
}
106+
}
107+
108+
async function checkErrorHandling(context: CheckContext): Promise<ConformanceFinding> {
109+
try {
110+
// Try calling a method that shouldn't exist
111+
await context.client.request(
112+
{ method: "observatory/nonexistent", params: {} },
113+
{ method: "object" } as never,
114+
{ timeout: Math.min(context.timeoutMs, 5000) },
115+
);
116+
// If we get here, server didn't error — that's actually acceptable per JSON-RPC
117+
return { rule: "error-handling", passed: true, detail: "Server responded to unknown method without crashing." };
118+
} catch (error) {
119+
// Getting an error is the expected behavior
120+
const err = error as Record<string, unknown>;
121+
const code = err["code"];
122+
if (typeof code === "number") {
123+
return { rule: "error-handling", passed: true, detail: `Server returned proper error code ${String(code)} for unknown method.` };
124+
}
125+
if (code !== undefined && code !== null) {
126+
return { rule: "error-handling", passed: true, detail: "Server returned an error response for unknown method." };
127+
}
128+
// Connection-level error is ok — server closed cleanly
129+
return { rule: "error-handling", passed: true, detail: "Server handled unknown method gracefully." };
130+
}
131+
}
132+
133+
export async function runConformanceCheck(context: CheckContext): Promise<ObservedCheck> {
134+
const startedAt = performance.now();
135+
136+
const asyncFindings = await Promise.all([
137+
checkToolsEndpoint(context),
138+
checkPromptsEndpoint(context),
139+
checkResourcesEndpoint(context),
140+
checkToolResponseContent(context),
141+
checkErrorHandling(context),
142+
]);
143+
144+
const findings = [
145+
checkCapabilitiesPresent(context),
146+
checkServerInfo(context),
147+
...asyncFindings,
148+
];
149+
150+
const passed = findings.filter(f => f.passed).length;
151+
const failed = findings.filter(f => !f.passed).length;
152+
const total = findings.length;
153+
154+
let status: "pass" | "partial" | "fail";
155+
if (failed === 0) {
156+
status = "pass";
157+
} else if (passed > failed) {
158+
status = "partial";
159+
} else {
160+
status = "fail";
161+
}
162+
163+
const message = failed === 0
164+
? `All ${total} conformance checks passed.`
165+
: `${passed}/${total} conformance checks passed, ${failed} failed.`;
166+
167+
const diagnostics = findings.map(f => `[${f.passed ? "pass" : "FAIL"}] ${f.rule}: ${f.detail}`);
168+
169+
const evidence: EvidenceSummary = {
170+
endpoint: "conformance/check",
171+
advertised: true,
172+
responded: true,
173+
minimalShapePresent: failed === 0,
174+
itemCount: total,
175+
identifiers: findings.filter(f => !f.passed).map(f => f.rule),
176+
diagnostics,
177+
};
178+
179+
return {
180+
result: makeCheckResult(
181+
"conformance",
182+
status,
183+
performance.now() - startedAt,
184+
message,
185+
[evidence],
186+
),
187+
};
188+
}

0 commit comments

Comments
 (0)