Publishes the MCP servers' health (claude mcp list, then the session's own MCP calls) as state; /mcp-health reports it; draws nothing

Publishes the MCP servers' health as state, for other mods to draw: the session pane shows them in its MCP section, a dot colored by status. It runs claude mcp list at session start and on /mcp-health, then follows the session's own MCP calls (subagents' included). It draws nothing itself.
| Status | Means |
|---|---|
idle | connected, not called yet this session |
ok | its last call answered |
warning | a call returned an error, or the server needs sign-in |
error | the health check could not connect, or a call found the server down |
No mod API lists the session's servers: the CLI's check stands in, so the list can differ from the session's (claude.ai connectors turned off by another auth source, servers added since start). A server it misses joins on its first call.
/mcp-health check the servers again, list their state and the errors seen this session
A consumer lists mcp-status under dependencies in its plugin.json, then reads:
const servers = await read($, atom({ plugin: "mcp-status", key: "servers" } as const, []));hooks/register.tsx 89 lines1// The MCP servers' health: listed by `claude mcp list` (at session start and on /mcp-health), then moved by the
2// session's own mcp__* calls; published as the `servers` and `notes` states. Drawing belongs to the consumers (the
3// session pane's MCP section). The atoms stay in this file with their read/update calls (the validator wants them
4// together); parsing and the status rules live in lib/servers.ts.
5import { atom, read, update } from "claude-code";
6import type { EngineInterface, Register } from "claude-code";
7
8import {
9 callOutcome,
10 failedCall,
11 formatHealth,
12 mergeHealth,
13 parseMcpList,
14 recordCall,
15 serverKeyOfTool,
16} from "./lib/servers";
17import type { CallOutcome } from "./lib/servers";
18
19const servers = atom({ plugin: "mcp-status", key: "servers" } as const, []);
20const notes = atom({ plugin: "mcp-status", key: "notes" } as const, []);
21
22// bumped by every check: a run that is no longer the latest drops its result
23let checkGeneration = 0;
24
25// no mod API lists the session's MCP servers or their state: the CLI's health check stands in, run in the session's
26// cwd so project servers count. It can differ from the session (another auth source, servers added since)
27// ponytail: ~3 s and it starts each stdio server once to probe it; a session-side status API if mods ever get one
28async function checkHealth($: EngineInterface) {
29 const thisGeneration = ++checkGeneration;
30 const listing = await $.process.run(["claude", "mcp", "list"]);
31 if (thisGeneration !== checkGeneration) return;
32 if (listing.exitCode !== 0) {
33 const reason = listing.stderr.trim() || `exit code ${listing.exitCode}`;
34 await update($, notes, () => [`claude mcp list failed: ${reason}`]);
35 return;
36 }
37 // the CLI writes its warnings to stderr and the servers to stdout
38 const parsed = parseMcpList(`${listing.stderr}\n${listing.stdout}`);
39 await update($, servers, (previous) => mergeHealth(previous, parsed.servers));
40 await update($, notes, () => parsed.notes);
41}
42
43async function recordOutcome(
44 $: EngineInterface,
45 key: string,
46 outcome: CallOutcome,
47) {
48 if (!outcome) return;
49 await update($, servers, (previous) => recordCall(previous, key, outcome));
50}
51
52export const register: Register = (on) => {
53 on("session.start", async ($, event, next) => {
54 await $.command.register({
55 name: "mcp-health",
56 description:
57 "Check the MCP servers again and list their state with the errors seen this session",
58 });
59 void checkHealth($).catch(() => {});
60
61 return next(event);
62 });
63
64 on("command.run", { command: "mcp-health" }, async ($) => {
65 await checkHealth($);
66
67 return { text: formatHealth(await read($, servers), await read($, notes)) };
68 });
69
70 // subagents' calls count too: the server is the same
71 on("tool.call", async ($, event, next) => {
72 const key = serverKeyOfTool(event.tool);
73 if (!key) return next(event);
74 try {
75 const result = await next(event);
76 void recordOutcome($, key, callOutcome(event.tool, result)).catch(
77 () => {},
78 );
79
80 return result;
81 } catch (error) {
82 void recordOutcome($, key, failedCall(event.tool, String(error))).catch(
83 () => {},
84 );
85 throw error;
86 }
87 });
88};
89hooks/lib/servers.ts 145 lines1import type { McpServer, McpStatus } from "../../types";
2
3// one `claude mcp list` server line: "<name>: <command or url> - <mark> <status words>"; a name may hold
4// colons (plugin:playwright:playwright) but never ": ", so the first ": " ends it
5const SERVER_LINE = /^(.+?): .* - (\S+)\s+(.+)$/;
6const HEALTHY_MARKS = new Set(["✔", "✓"]);
7const FAILED_MARKS = new Set(["✗", "✘"]);
8// a line of the CLI's own about the listing, such as the claude.ai connectors being disabled
9const WARNING_LINE = /^[⚠!]\s*(.+)$/;
10// `mcp__<server>__<tool>`
11const MCP_TOOL = /^mcp__(.+?)__(.+)$/;
12// the words a call's error carries when the server itself is down rather than the call wrong
13// ponytail: a word list over the error text, the engine gives no error kind; extend it as new wordings show up
14const SERVER_DOWN =
15 /not connected|connection closed|ECONNREFUSED|failed to connect|timed out|MCP error -32000/i;
16const MAX_ISSUES = 5;
17const ISSUE_CHARS = 160;
18
19export type ListedServer = {
20 name: string;
21 key: string;
22 health: string;
23 status: McpStatus;
24};
25
26// a call's verdict, or null for one that says nothing on the server (the person refused it)
27export type CallOutcome = { status: McpStatus; issue?: string } | null;
28
29// how tool names spell a server: what is not a letter, digit, _ or - becomes _ (`claude.ai Asana` -> `claude_ai_Asana`)
30export const serverKey = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_");
31
32// the server key in an MCP tool's name, undefined for another tool
33export const serverKeyOfTool = (toolName: string) =>
34 MCP_TOOL.exec(toolName)?.[1];
35
36export function parseMcpList(listing: string): {
37 servers: ListedServer[];
38 notes: string[];
39} {
40 const servers: ListedServer[] = [];
41 const notes: string[] = [];
42 for (const line of listing.split("\n").map((text) => text.trim())) {
43 const warning = WARNING_LINE.exec(line);
44 if (warning) {
45 notes.push(warning[1] ?? line);
46 continue;
47 }
48 const server = SERVER_LINE.exec(line);
49 if (!server) continue;
50 const [, name = "", mark = "", health = ""] = server;
51 servers.push({
52 name,
53 key: serverKey(name),
54 health,
55 status: HEALTHY_MARKS.has(mark)
56 ? "ok"
57 : FAILED_MARKS.has(mark)
58 ? "error"
59 : "warning",
60 });
61 }
62 return { servers, notes };
63}
64
65// the latest word wins: a healthy check reads ok once the server was called this session, idle before;
66// a server the check does not list (seen only through its calls) stays as it was
67export function mergeHealth(
68 previous: readonly McpServer[],
69 listed: readonly ListedServer[],
70): McpServer[] {
71 const merged = listed.map((server): McpServer => {
72 const known = previous.find((entry) => entry.key === server.key);
73 const wasCalled = known !== undefined && known.status !== "idle";
74 return {
75 name: server.name,
76 key: server.key,
77 health: server.health,
78 status: server.status === "ok" && !wasCalled ? "idle" : server.status,
79 issues: known?.issues ?? [],
80 };
81 });
82 const unlisted = previous.filter(
83 (entry) => !listed.some((server) => server.key === entry.key),
84 );
85 return [...merged, ...unlisted.map((entry) => ({ ...entry, health: null }))];
86}
87
88// what a tool.call result says on its server; `toolName` is the full mcp__ name
89export function callOutcome(
90 toolName: string,
91 result: { deny?: string; isError?: boolean; text?: string },
92): CallOutcome {
93 if (result.deny !== undefined) return null;
94 if (!result.isError) return { status: "ok" };
95 return failedCall(toolName, result.text ?? "error");
96}
97
98// a call that errored, or threw: red when the words say the server is down, yellow when only the call failed
99export function failedCall(toolName: string, errorText: string): CallOutcome {
100 const shortTool = MCP_TOOL.exec(toolName)?.[2] ?? toolName;
101 const firstLine = errorText.split("\n").find(Boolean) ?? errorText;
102 return {
103 status: SERVER_DOWN.test(errorText) ? "error" : "warning",
104 issue: `${shortTool}: ${firstLine.slice(0, ISSUE_CHARS)}`,
105 };
106}
107
108// a server unknown so far (not listed by the check) joins under its key
109export function recordCall(
110 servers: readonly McpServer[],
111 key: string,
112 outcome: NonNullable<CallOutcome>,
113): McpServer[] {
114 const known = servers.find((server) => server.key === key) ?? {
115 name: key,
116 key,
117 status: "idle",
118 health: null,
119 issues: [],
120 };
121 const updated: McpServer = {
122 ...known,
123 status: outcome.status,
124 issues: outcome.issue
125 ? [...known.issues, outcome.issue].slice(-MAX_ISSUES)
126 : known.issues,
127 };
128 return servers.some((server) => server.key === key)
129 ? servers.map((server) => (server.key === key ? updated : server))
130 : [...servers, updated];
131}
132
133// /mcp-health's report: one line per server, its issues under it, then the check's notes
134export function formatHealth(
135 servers: readonly McpServer[],
136 notes: readonly string[],
137): string {
138 if (servers.length === 0 && notes.length === 0) return "No MCP server.";
139 const lines = servers.flatMap((server) => [
140 `${server.status.padEnd(7)} ${server.name}${server.health ? ` · ${server.health}` : ""}`,
141 ...server.issues.map((issue) => ` ${issue}`),
142 ]);
143 return [...lines, ...notes.map((note) => `⚠ ${note}`)].join("\n");
144}
145types/index.d.ts 29 lines1// The MCP servers as this mod publishes them. Consumers list "mcp-status" under `dependencies` in their plugin.json,
2// import these types from "mcp-status", and read them with
3// read($, atom({ plugin: "mcp-status", key: "servers" } as const, []))
4// idle: connected, not called yet this session; ok: answered; warning: a call errored or it needs sign-in;
5// error: the health check failed or a call found it down
6export type McpStatus = 'idle' | 'ok' | 'warning' | 'error'
7
8export type McpServer = {
9 // the name /mcp and `claude mcp list` show; a server only seen through its calls keeps its tool-name spelling
10 name: string
11 // the name as tool names spell it (`plugin_playwright_playwright`), how calls find their server
12 key: string
13 status: McpStatus
14 // the health check's own words (`Connected`, `Failed to connect`); null when it did not list the server
15 health: string | null
16 // the last errors and warnings seen this session, oldest first
17 issues: string[]
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'mcp-status': {
23 servers: McpServer[]
24 // what the health check said beside the servers (a warning line, its own failure)
25 notes: string[]
26 }
27 }
28}
29