SLOPSHOPPER

self-mcp

Lets a session reconnect an MCP server that broke (one by name, or all) once the current turn ends, as /mcp reconnect does, then resume on its own.

newguardtoaststatustooltimer
A shopper browsing a rack in a slop shop
README

self-mcp

Lets a session reconnect one of its MCP servers once the current turn ends, as /mcp reconnect <server> does, then carry on by itself. Useful when a server's tools stop answering and a reconnect is all it needs: a browser extension that dropped its connection, a local server that crashed or timed out. Part of claude-code-self-tools.

Tool

reconnect_mcp_after_turn (Claude sees mcp__self-mcp__reconnect_mcp_after_turn):

  • server: the server's name as /mcp lists it, or all for every server that isn't connected. For a tool named mcp__<server>__<tool>, the name is usually <server>.
  • resume (optional): the message that starts the session's next turn once the reconnect has run, saying what to try again.

How it behaves

  • The reconnect waits for the turn to end. The call queues it; /mcp reconnect <server> runs once the turn ends with Claude's answer, and shows in the transcript as if you had typed it. That is the typed form of the /mcp menu's Reconnect, so no menu opens and nothing waits on you.
  • The outcome is Claude Code's own line. /mcp reconnect prints whether it reconnected (for example Successfully reconnected to claude-in-chrome) in the transcript, where you and Claude both read it. It hands the plugin no text, so the toast and the resume point to that line. With resume, the session sends itself "MCP reconnect of <server> ran: … Resume: …", which starts a new turn. Without resume, Claude is told on its next turn and the session waits for your message.
  • A reconnect starts the server's session over. Whatever the server kept for this session is gone. Claude in Chrome, for one, no longer knows the session's tab group: the group stays open in Chrome, and the session makes a new one.
  • It reconnects, nothing more. A server that needs sign-in, or one you turned off, stays yours to handle in /mcp.
  • An interrupted turn cancels it, with a notice.
  • Subagents can't use it. A subagent that calls the tool is told to finish its piece and say which server needs a reconnect.

Install

/plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-mcp@claude-code-self-tools
Source 2 files
hooks/register.ts 143 lines
1// A session reconnects one of its MCP servers that broke, or all of them, once the current turn
2// ends, as `/mcp reconnect <server>` does, and can carry on by itself afterwards. The model calls
3// reconnect_mcp_after_turn; a command can't run while the turn the model is in waits on it, so the
4// call is queued and the reconnect runs once the turn has ended with an answer. An interrupted turn
5// cancels it. Only the main conversation reconnects this way, never a subagent.
6//
7// `/mcp reconnect` is the typed form of the /mcp menu's Reconnect: it opens no menu, so it runs
8// unattended. Its outcome (reconnected, failed, no such server) is its own transcript line, as if
9// typed; the toast and the resume, or a note the model reads, point to it.
10import type { EngineInterface, Register } from 'claude-code'
11import type { Queued } from '../types'
12
13const queued = { plugin: 'self-mcp', key: 'queued' } as const
14
15/** A beat after the turn's end, so the turn is no longer running when the command starts. */
16const SETTLE_MS = 500
17
18const DESCRIPTION = [
19  'Reconnects one of this session\'s MCP servers once the current turn ends, as /mcp reconnect does: for a server whose tools stopped answering (a browser extension that dropped, a server that crashed or timed out), where reconnecting is all it needs.',
20  '`server` is its name as /mcp lists it; for a tool named mcp__<server>__<tool> that is usually <server>. `all` reconnects every server that is not connected.',
21  'Not for a server that needs sign-in, or one turned off: /mcp is the person\'s for those.',
22  '`resume` is the message that starts your next turn once the reconnect has run, with what the reconnect said: what to try again.',
23  'Give it whenever you have a next step of your own; leave it out only when the next step waits on someone else (the user, another session, a check).',
24  'End the turn after calling it; the reconnect runs once your answer is shown.',
25].join(' ')
26
27/** Trimmed text, or undefined when it is not a string or is blank. */
28function text(raw: unknown): string | undefined {
29  const trimmed = typeof raw === 'string' ? raw.trim() : ''
30  return trimmed === '' ? undefined : trimmed
31}
32
33/** Why a call was refused, as one line. */
34function why(err: unknown): string {
35  const said = (err instanceof Error ? err.message : String(err)).trim()
36  return said === '' ? 'no reason given' : said
37}
38
39/** One line: the command's output can run over several. */
40const oneLine = (said: string) => said.replace(/\s*\n\s*/g, ' ')
41
42/** What is queued: `resume` only when there is one, since stored state holds no undefined. */
43const entry = (server: string, resume: string | undefined): Queued => (resume === undefined ? { server } : { server, resume })
44
45/** Tells the model on its next turn, in a row it reads and the person doesn't see as typed. */
46async function tellModel($: EngineInterface, note: string): Promise<void> {
47  try {
48    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
49  } catch {
50    // The toast has told the person; there is no other way to reach the model.
51  }
52}
53
54async function sendResume($: EngineInterface, said: string, resume: string): Promise<void> {
55  try {
56    const sent = await $.prompt.submit({ text: `${said} Resume: ${resume}` })
57    if (sent.drop !== undefined) $.ui.toast(`Resume not sent: ${sent.drop}`)
58  } catch {
59    $.ui.toast('Resume not sent; the session waits for the next message.')
60  }
61}
62
63/** Runs the reconnect, then tells the person and the model what it said. */
64async function reconnectNow($: EngineInterface, value: Queued): Promise<void> {
65  $.ui.status(undefined)
66  let said: string
67  try {
68    const output = text((await $.command.run({ command: 'mcp', args: `reconnect ${value.server}` })).text)
69    // /mcp shows its outcome as its own transcript line, which the person and the model both read,
70    // and hands the caller no text; a version that hands one back has it passed on.
71    said =
72      output === undefined
73        ? `MCP reconnect of ${value.server} ran: Claude Code's own line above says whether it reconnected.`
74        : `MCP reconnect of ${value.server}: ${oneLine(output)}`
75  } catch (err) {
76    said = `MCP reconnect of ${value.server} didn't run: ${why(err)}.`
77  }
78  $.ui.toast(said)
79  if (value.resume !== undefined) await sendResume($, said, value.resume)
80  else await tellModel($, said)
81}
82
83export const register: Register = on => {
84  on('session.start', async ($, e, next) => {
85    const started = await next(e)
86    await $.tool.register({
87      name: 'reconnect_mcp_after_turn',
88      description: DESCRIPTION,
89      inputSchema: {
90        type: 'object',
91        properties: {
92          server: {
93            type: 'string',
94            description: 'The MCP server to reconnect, by its name as /mcp lists it (usually the <server> in mcp__<server>__<tool>), or `all`.',
95          },
96          resume: {
97            type: 'string',
98            description:
99              'The message that starts your next turn once the reconnect has run: what to try again. Leave it out only when the next step waits on someone else.',
100          },
101        },
102        required: ['server'],
103        additionalProperties: false,
104      },
105      isDeferred: false,
106    })
107    return started
108  })
109
110  on('tool.call', { tool: 'mcp__self-mcp__reconnect_mcp_after_turn' }, async ($, e) => {
111    if (e.agentId !== undefined) {
112      return {
113        deny: "A subagent can't reconnect the session's MCP servers. Finish your piece and return its result, saying which server needs a reconnect: the main session can reconnect it.",
114      }
115    }
116    const input = e as { server?: unknown; resume?: unknown }
117    const server = text(input.server)
118    if (server === undefined || /[\r\n]/.test(server)) {
119      return { deny: '`server` must be one MCP server\'s name as /mcp lists it, or `all`, on one line.' }
120    }
121    const resume = text(input.resume)
122    await $.state.set(queued, entry(server, resume))
123    $.ui.status(`MCP reconnect of ${server} queued for when this turn ends`)
124    const then = resume === undefined ? 'then waits for the next message' : `then resumes with: ${resume}`
125    return { result: `Queued. ${server} reconnects once this turn ends, and the session ${then}` }
126  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-mcp: queuing the reconnect failed; nothing is queued.' }))
127
128  on('turn.complete', async ($, e, next) => {
129    const done = await next(e)
130    if (e.agentId !== undefined) return done
131    const { value } = await $.state.get(queued)
132    if (!value) return done
133    await $.state.set(queued, null)
134    if (e.isAborted || e.reason !== 'answer') {
135      $.ui.status(undefined)
136      $.ui.toast("MCP reconnect cancelled: the turn didn't finish.")
137      return done
138    }
139    $.clock.after(SETTLE_MS, () => void reconnectNow($, value))
140    return done
141  })
142}
143
types/index.d.ts 13 lines
1/**
2 * A reconnect the session asked for, waiting for its turn to end: the server, as /mcp lists it or
3 * `all`, and the message it resumes with once the reconnect has run (absent: it waits for the next
4 * message).
5 */
6export type Queued = { server: string; resume?: string }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'self-mcp': { queued: Queued | null }
11  }
12}
13