SLOPSHOPPER

mcp-doctor

Tells you when an MCP server failed to connect or dropped, with the engine's reason and a reconnect button, and when it is back; claude.ai connectors are left…

newcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mcp-doctor
› 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-doctor ⎿ mcp-doctor: on · every watched MCP server is connected ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mcp-doctor

When an MCP server fails to connect or drops, the engine tells only the model; you find out why a tool does not work only when the model runs into it. This mod tells you, with a button that reconnects the server, and says so again when the server is back.

What it does

  1. At session start, at the end of each main-loop turn, after each engine note about deferred tools, after /mcp-doctor on and after a reconnect, the mod reads the engine's own list of servers that are not connected. It asks the built-in ToolSearch tool, whose result names each failed server (failed_mcp_servers) and each server still connecting (pending_mcp_servers). The engine adds that list only to an answer with no match, so the query selects a tool that cannot exist. The call leaves nothing in the model's context.
  2. The engine's deferred_tools_delta note to the model is read too: its "configured but failed to connect" block names failed servers, and its "available again (MCP server reconnected)" line names the tool prefixes that came back. The note reaches the model unchanged.
  3. A server that is not connected gets one section in the sidebar that stays for the session, with the engine's reason and a reconnect button. not connected is red and the reason faint:

flaky: not connected (CONNECTION_CLOSED: Connection closed) [ reconnect flaky ]

While the sidebar is closed, one transcript line says the same and names the command: flaky: not connected (disconnected); /mcp-doctor reconnect flaky. The section is drawn at the next measure after the sidebar opens.

  1. The button runs /mcp-doctor reconnect <server>, which asks the engine to run /mcp reconnect <server> and reads the list again. A server still failed afterwards gets one line with the engine's answer.
  2. A server that is back loses its section and gets one line with connected again in green: flaky: connected again. A server still connecting is left as it is.
  3. The same failure is written once. A later turn that finds the same server failed writes nothing.

claude.ai connectors (servers named claude.ai <name>) are left out, because they belong to the account and not to this machine's config. The model gets no note from this mod, because the engine already tells it.

Command

/mcp-doctor the setting and every server that is not connected /mcp-doctor reconnect <server> asks the engine to reconnect one server /mcp-doctor on | off on by default

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install mcp-doctor@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod for the section and its button. Without it the mod writes one transcript line per server.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=mcp-doctor}, turn.complete, prompt.attachment{type=deferred_tools_delta} ❯ ./register.ts calls: $.clock.after (via later, runCommand), $.command.register, $.command.run (via reconnect), $.sidebar.clear (via showBack), $.sidebar.set (via placeFailed, showBack), $.store.get (via readSettings), $.store.set (via setEnabled), $.tool.call (via measure), $.ui.log (via addFailures, later, measure, reconnect, showBack)

Reach L2, it drives Claude: it runs the /mcp reconnect command.

  1. Reads: the engine's list of failed and connecting MCP servers (a ToolSearch result), and the engine's deferred_tools_delta note. It reads no file, no prompt and no answer.
  2. Runs: ToolSearch once per session start, per turn end and per deferred_tools_delta note; /mcp reconnect <server> once per press of the button
  3. Sends: nothing to the model and nothing to the network
  4. Persists: in $.store, the on/off setting
  5. Hostile input: the server names and error texts come from the engine and the server config; they are drawn as text and never run, and the reconnect command takes the name only as its argument

Limits

  • Reconnecting works in an interactive session alone. A headless session (claude -p) answers Reconnect, enable, and disable aren't available in this session., and the mod writes that answer.
  • The engine gives no error for a server that dropped during the session, so its line reads disconnected.
  • A build where ToolSearch does not answer (tool search turned off) is read through the engine's notes alone, and the mod says so once.
  • The engine's note names a reconnected server by its tool prefix. Two server names that map to one prefix (a.b and a_b) are closed together.
  • A subagent's turn does not trigger a measure; only the main loop's end does.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 188 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { backLines, backText, deltaOf, failedLines, failedOf, failedText, prefixOf, reconnectButton, sectionKey, statusText, type Failure } from './doctor.ts'
3
4const ENABLED_KEY = 'enabled'
5const CONSUMER = 'mcp-doctor'
6const USAGE = 'expects nothing (the status), reconnect <server>, on or off'
7
8/**
9 * The on/off setting, every watched server that is not connected with its reason, the failed servers
10 * whose section the sidebar has not taken yet, whether ToolSearch answers here, and the measure in
11 * flight, so two measures never interleave.
12 */
13type State = { enabled: boolean; failed: Map<string, string>; unplaced: Set<string>; toolSearch: 'ok' | 'missing'; chain: Promise<void> }
14
15/**
16 * Reads the on/off setting from the store, which every window shares, so a change made in another
17 * window applies here at the next hook that acts on it.
18 */
19async function readSettings($: EngineInterface, state: State): Promise<void> {
20  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
21}
22
23function errorText(err: unknown): string {
24  return err instanceof Error ? err.message : String(err)
25}
26
27/** A server that is not connected as a standing section with a reconnect button; false while the sidebar is closed or missing. */
28async function placeFailed($: EngineInterface, f: Failure): Promise<boolean> {
29  try {
30    return await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(`failed-${f.name}`), title: 'MCP server', lines: failedLines(f), buttons: [reconnectButton(f.name)], until: 'session', order: 15 })
31  } catch {
32    // The sidebar mod is not installed.
33    return false
34  }
35}
36
37/** A server that came back: its standing section goes, and one green line says so. */
38async function showBack($: EngineInterface, name: string): Promise<void> {
39  try {
40    await $.sidebar.clear({ consumer: CONSUMER, key: sectionKey(`failed-${name}`) })
41    if (await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(`back-${name}`), title: 'MCP server', lines: backLines(name), until: 'stream' })) return
42  } catch {
43    // The sidebar mod is not installed.
44  }
45  $.ui.log(backText(name))
46}
47
48/**
49 * Records and shows every server that failed. A new one the sidebar does not take gets one transcript
50 * line, and its section is drawn at the next measure after the sidebar opens.
51 */
52async function addFailures($: EngineInterface, state: State, failures: readonly Failure[]): Promise<void> {
53  for (const f of failures) {
54    const isKnown = state.failed.has(f.name)
55    if (isKnown && !state.unplaced.has(f.name)) continue
56    state.failed.set(f.name, f.reason)
57    if (await placeFailed($, f)) {
58      state.unplaced.delete(f.name)
59      continue
60    }
61    if (!isKnown) $.ui.log(`${failedText(f)}; /mcp-doctor reconnect ${f.name}`)
62    state.unplaced.add(f.name)
63  }
64}
65
66/** Drops and shows every known failed server the predicate says is connected again. */
67async function dropFailures($: EngineInterface, state: State, isBack: (name: string) => boolean): Promise<void> {
68  for (const name of [...state.failed.keys()]) {
69    if (!isBack(name)) continue
70    state.failed.delete(name)
71    state.unplaced.delete(name)
72    await showBack($, name)
73  }
74}
75
76/**
77 * Reads the engine's own list of failed servers through ToolSearch. The call leaves nothing in the
78 * model's context. The engine adds the list only to an answer with no match, so the query selects a
79 * tool that cannot exist. A build where ToolSearch does not answer falls back to the attachments alone.
80 */
81async function measure($: EngineInterface, state: State): Promise<void> {
82  if (state.toolSearch === 'missing') return
83  // The measure runs from a timer, so the setting is read when it fires.
84  await readSettings($, state)
85  if (!state.enabled) return
86  let r
87  try {
88    r = await $.tool.call({ tool: 'ToolSearch', query: 'select:mcp-doctor-no-such-tool', max_results: 1 })
89  } catch (err) {
90    r = { deny: errorText(err) }
91  }
92  if (r.deny !== undefined || r.isError === true) {
93    state.toolSearch = 'missing'
94    $.ui.log(`ToolSearch does not answer here, so only the engine's notes are read: ${r.deny ?? r.text ?? 'no reason given'}`)
95    return
96  }
97  const result = r.result as { pending_mcp_servers?: unknown }
98  const pending = new Set(Array.isArray(result.pending_mcp_servers) ? (result.pending_mcp_servers as unknown[]).filter((n): n is string => typeof n === 'string') : [])
99  const now = failedOf(r.result)
100  const names = new Set(now.map(f => f.name))
101  await addFailures($, state, now)
102  await dropFailures($, state, name => !names.has(name) && !pending.has(name))
103}
104
105/** Runs one measure after the one in flight, off the hook that asked, so no turn waits on ToolSearch. */
106function later($: EngineInterface, state: State): void {
107  $.clock.after(0, () => {
108    state.chain = state.chain.then(() => measure($, state)).catch(err => $.ui.log(`the MCP servers were not read: ${errorText(err)}`))
109  })
110}
111
112/** What one `deferred_tools_delta` attachment says, applied at once; the measure after it has the last word. */
113async function fromDelta($: EngineInterface, state: State, text: string): Promise<void> {
114  const delta = deltaOf(text)
115  await addFailures($, state, delta.failed)
116  const back = new Set(delta.reconnected)
117  await dropFailures($, state, name => back.has(prefixOf(name)))
118}
119
120/**
121 * Asks the engine to reconnect one server. `/mcp` cannot run inside the command's own hook (the host
122 * refuses it), so it runs from a timer; a server still failed afterwards says so with the engine's answer.
123 */
124async function reconnect($: EngineInterface, state: State, name: string): Promise<void> {
125  let answer = ''
126  try {
127    answer = (await $.command.run({ command: 'mcp', args: `reconnect ${name}` })).text ?? ''
128  } catch (err) {
129    answer = errorText(err)
130  }
131  await measure($, state)
132  if (state.failed.has(name)) $.ui.log(`${name} is still not connected after /mcp reconnect${answer === '' ? '' : `: ${answer}`}`)
133}
134
135async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
136  state.enabled = on
137  await $.store.set(ENABLED_KEY, on)
138  if (on) later($, state)
139  return on ? 'on: the MCP servers are read at session start and at the end of each turn' : 'off: the MCP servers are not read'
140}
141
142async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
143  const word = args.trim()
144  if (word === 'on' || word === 'off') return setEnabled($, state, word === 'on')
145  if (word.startsWith('reconnect ')) {
146    const name = word.slice('reconnect '.length).trim()
147    $.clock.after(100, () => void reconnect($, state, name))
148    return `reconnecting ${name}`
149  }
150  if (word !== '') return USAGE
151  await readSettings($, state)
152  return statusText(state.enabled, [...state.failed].map(([name, reason]) => ({ name, reason })))
153}
154
155export const register: Register = on => {
156  const state: State = { enabled: true, failed: new Map(), unplaced: new Set(), toolSearch: 'ok', chain: Promise.resolve() }
157
158  on('session.start', async ($, e, next) => {
159    const r = await next(e)
160    await readSettings($, state)
161    await $.command.register({ name: 'mcp-doctor', description: 'MCP servers that are not connected: status, reconnect <server>, on, off (mcp-doctor)', argumentHint: '[reconnect <server> | on | off]', immediate: true })
162    if (state.enabled) later($, state)
163    return r
164  })
165
166  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
167  on('command.run', { command: 'mcp-doctor' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
168
169  on('turn.complete', async ($, e, next) => {
170    const r = await next(e)
171    // A subagent's turn ends inside the main loop's; the main loop's end is enough.
172    if (e.agentId !== undefined) return r
173    await readSettings($, state)
174    if (state.enabled) later($, state)
175    return r
176  })
177
178  // The engine tells the model itself; the person reads the same fact here. The text is not changed.
179  on('prompt.attachment', { type: 'deferred_tools_delta' }, async ($, e, next) => {
180    await readSettings($, state)
181    if (state.enabled) {
182      await fromDelta($, state, e.text)
183      later($, state)
184    }
185    return next(e)
186  })
187}
188
hooks/doctor.ts 111 lines
1/** What the engine says about its MCP servers, and the texts this mod writes. */
2
3/** One server that is not connected, and why, as far as the engine said. */
4export type Failure = { name: string; reason: string }
5
6/** The `failed_mcp_servers` rows of a ToolSearch result (measured on 2.1.280). */
7type FailedRow = { name?: unknown; errorCode?: unknown; error?: unknown }
8
9const FAILED_HEAD = 'The following MCP servers are configured but failed to connect'
10const RECONNECTED = /available again \(MCP server reconnected[^)]*\):([^\n]*)/g
11const PREFIX = /mcp__([A-Za-z0-9_-]+?)__\*/g
12
13/** A claude.ai connector is the person's account, not this machine's config; the mod leaves it alone. */
14export function isWatched(name: string): boolean {
15  return !name.startsWith('claude.ai ')
16}
17
18/** The tool-name prefix the engine builds from a server name: every character but a letter, a digit or `-` turned into `_`. */
19export function prefixOf(name: string): string {
20  return name.replace(/[^A-Za-z0-9-]/g, '_')
21}
22
23function reasonOf(row: FailedRow): string {
24  const code = typeof row.errorCode === 'string' ? row.errorCode : undefined
25  const error = typeof row.error === 'string' ? row.error : undefined
26  if (code !== undefined && error !== undefined) return `${code}: ${error}`
27  return code ?? error ?? 'disconnected'
28}
29
30/** The watched servers a ToolSearch result names as failed. A server that dropped mid-session carries no error. */
31export function failedOf(result: unknown): Failure[] {
32  const rows = (result as { failed_mcp_servers?: unknown } | undefined)?.failed_mcp_servers
33  if (!Array.isArray(rows)) return []
34  return (rows as FailedRow[])
35    .filter((r): r is FailedRow & { name: string } => typeof r.name === 'string' && isWatched(r.name))
36    .map(r => ({ name: r.name, reason: reasonOf(r) }))
37}
38
39/** One `<name>` or `<name>: "<error>"` line of the failed block. */
40function failureOfLine(line: string): Failure {
41  const at = line.indexOf(': "')
42  if (at < 0) return { name: line.trim(), reason: 'failed to connect' }
43  return { name: line.slice(0, at).trim(), reason: line.slice(at + 3).replace(/"\s*$/, '') }
44}
45
46/** The servers the attachment names as failed: the lines after its head, up to the first empty line. */
47function failedInDelta(text: string): Failure[] {
48  const at = text.indexOf(FAILED_HEAD)
49  if (at < 0) return []
50  const body = text.slice(text.indexOf('\n', at) + 1)
51  const end = body.indexOf('\n\n')
52  return (end < 0 ? body : body.slice(0, end)).split('\n').filter(l => l.trim() !== '').map(failureOfLine).filter(f => isWatched(f.name))
53}
54
55/** The tool prefixes the attachment names as reconnected (`mcp__plugin_playwright_playwright__* (25)`). */
56function reconnectedInDelta(text: string): string[] {
57  return [...text.matchAll(RECONNECTED)].flatMap(m => [...(m[1] ?? '').matchAll(PREFIX)].map(p => p[1] ?? ''))
58}
59
60/** What one `deferred_tools_delta` attachment says: the servers that failed, and the tool prefixes that came back. */
61export function deltaOf(text: string): { failed: Failure[]; reconnected: string[] } {
62  return { failed: failedInDelta(text), reconnected: reconnectedInDelta(text) }
63}
64
65/** The line the person reads for a server that is not connected. The engine adds the mod name. */
66export function failedText(f: Failure): string {
67  return `${f.name}: not connected (${f.reason})`
68}
69
70export function backText(name: string): string {
71  return `${name}: connected again`
72}
73
74/** How the sidebar colours a line or a part of one. */
75type Tone = 'ok' | 'warn' | 'error' | 'dim'
76export type Part = { text: string; kind?: Tone }
77/** A sidebar line, as the sidebar mod's contract names it; `text` holds the whole line for a sidebar that draws no parts. */
78export type Line = { text: string; kind?: Tone; parts?: Part[] }
79
80const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
81
82/** A line made of parts, its `text` their texts joined. */
83const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
84
85/** The failed server's line: the name default, `not connected` red, the reason faint. */
86export function failedLines(f: Failure): Line[] {
87  return [partsLine([part(`${f.name}: `, undefined), part('not connected', 'error'), part(` (${f.reason})`, 'dim')])]
88}
89
90/** The reconnected server's line: the name default, `connected again` green. */
91export function backLines(name: string): Line[] {
92  return [partsLine([part(`${name}: `, undefined), part('connected again', 'ok')])]
93}
94
95/** The reconnect button under a failed server: pressing it runs `/mcp-doctor reconnect <name>`. */
96export function reconnectButton(name: string): { label: string; command: string; args: string } {
97  return { label: `reconnect ${name}`, command: 'mcp-doctor', args: `reconnect ${name}` }
98}
99
100/** A sidebar section key: the subject cut to what the sidebar takes. */
101export function sectionKey(text: string): string {
102  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'server'
103}
104
105/** The `/mcp-doctor` answer: the setting and every server that is not connected. */
106export function statusText(enabled: boolean, failed: readonly Failure[]): string {
107  const head = enabled ? 'on' : 'off'
108  if (failed.length === 0) return `${head} · every watched MCP server is connected`
109  return `${head} · ${failed.map(failedText).join('; ')}`
110}
111