SLOPSHOPPER

mcp-status

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

newguardcommandprocess
v0.1.0no licenseupdated 2026-10-21djavrell/dotfile/claude/mods/mcp-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mcp-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mcp-health ⎿ mcp-status: No MCP server. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mcp-status

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.

StatusMeans
idleconnected, not called yet this session
okits last call answered
warninga call returned an error, or the server needs sign-in
errorthe 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.

Example

/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, []));
Source 3 files
hooks/register.tsx 89 lines
1// 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};
89
hooks/lib/servers.ts 145 lines
1import 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}
145
types/index.d.ts 29 lines
1// 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