SLOPSHOPPER

mcp-accounts

Several logins for any OAuth MCP server (Slack workspaces, Supabase accounts, anything else by URL), each signed in once, with the active one picked per…

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mcp-accounts
│ ┃ MCP accounts ✕ › fix the failing auth test and add an audit log call │ ┃ No accounts yet: /account add <service> │ ┃ <account> ⏺ Read(src/auth.ts) │ ┃ Enter on a name uses only it · + adds, − ⎿ Read 6 lines │ ┃ drops · Esc closes ⏺ 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 │ │ › /account │ ⎿ mcp-accounts: No accounts yet. │ ⎿ mcp-accounts: │ ⎿ mcp-accounts: /account pick in │ ⎿ mcp-accounts: /account <service> <account> [<account>…] use tho │ ⎿ mcp-accounts: /account add <service> <account> [url] add an a │ ⎿ mcp-accounts: /account remove <service> <account> remove o │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · MCP accounts
No accounts yet: /account add <service> <account> Enter on a name uses only it · + adds, − drops · Esc closes
README

mcp-accounts

Several logins for the same MCP server, each signed in once, and /account to pick which ones a project uses: one, or as many as you need. Six Slack workspaces, a personal and a client Supabase, two Linears: add each as an account, and switching between them is instant, with no restart. The accounts a project isn't using are blocked, so nothing posts to the wrong workspace.

/account add slack acme          → Run /reload-plugins to load it, then sign it in: /mcp → plugin:mcp-accounts:slack-acme → Authenticate …
/account add slack sidegig
/account                         → opens the picker:
                                   ╭──────────────────────────────────────────────────────────────╮
                                   │ slack                                                        │
                                   │ ● acme     −                                                 │
                                   │   sidegig  +                                                 │
                                   │ Enter on a name uses only it · + adds, − drops · Esc closes  │
                                   ╰──────────────────────────────────────────────────────────────╯
Claude calls slack-sidegig       → refused: This project uses slack acme, not sidegig …
/account slack acme sidegig      → slack → acme and sidegig: both work, any others stay blocked

Built and tested on Claude Code 2.1.310.

Install

/plugin install mcp-accounts --marketplace JesseVent/claude-mods

Setup

  1. Add each account: /account add <service> <account>. Slack and Supabase are built in. For any other remote MCP server, give its URL with the first account: /account add linear work https://mcp.linear.app/mcp. Later accounts of that service take no URL.
  2. Load them: run /reload-plugins. The new servers join the session with no restart.
  3. Sign each one in, once: /mcp → plugin:mcp-accounts:<service>-<account> → Authenticate. The browser opens on that service's sign-in.
  4. Slack: pick the workspace in the top-right switcher before you allow.
  5. Supabase or another per-login service: the browser has to be logged in as that account. Use a private window or another browser profile for the second login.

Claude Code stores each account's sign-in separately, and it lasts across sessions.

  1. Pick per project: /account opens the picker. Enter (or a click) on a name uses that account alone and closes the picker. + beside an account adds it to those in use, and − drops it, with the picker staying open. Tab or the arrow keys move between them. Without the picker: /account slack acme for one, /account slack acme sidegig for several. A service with a single account needs no pick.

Turn off the official Slack and Supabase plugins (and the claude.ai Slack connector, if you have it) once their accounts are here, so Claude doesn't see two sets of the same tools.

Commands

CommandWhat it does
/accountOpens the picker: each service's accounts, ● on those this project uses. A name uses that account alone; + and − add and drop one (Esc closes). Also lists every account, whether it's connected or still needs sign-in, with * on those in use
/account <service> <account> [<account>…]Uses those accounts in this project (the project root), remembered across sessions; the service's other accounts stay blocked
/account <service>Lists that service's accounts
/account add <service> <account> [url]Adds an account; the URL only for a service's first account, when it isn't built in
/account remove <service> <account>Removes one; /reload-plugins drops its server

Service names are lowercase letters and digits. Account names are lowercase letters, digits and dashes, up to 30 characters, because they become server and tool names.

How it works

  • One server per account. Each account is its own MCP server in this mod's manifest, cloned from its service (URL, plus the OAuth client for Slack). Claude Code keys a server's sign-in by its name, so each account keeps its own login.
  • Each account's URL carries ?account=<name>. Claude Code runs servers that share a URL as one server under one login, and a different header doesn't change that. The tag never reaches the sign-in: Slack's and Supabase's OAuth discovery name the bare server as the resource.
  • The account list lives in the mod's store, which survives updates. At each start the mod rebuilds its server file from that list. If that changed anything (say an update replaced the file), it runs /reload-plugins itself.
  • The gate. A call to the tools of an account this project doesn't use is refused before it runs, and so is a read of its MCP resources (ReadMcpResourceTool and the other resource tools, matched by the server they name).
  • The reason names the accounts in use and their tool prefixes, plus the /account command that would add the blocked one.
  • Signing an account in is never blocked.
  • With several accounts and none picked, every call is refused until you pick.
  • If the check itself fails, the call is refused too.
  • A resource listing that names no server is always refused, since it would reach every server; Claude names one and retries. Which of this mod's servers are loaded can't be told reliably: a removed account's server stays until the reload, and a server with resources but no tools leaves no trace.
  • Claude knows. The system prompt's environment section names the accounts each service uses here and their tool prefixes (mcp__plugin_mcp-accounts_slack-acme__*). With several in use, Claude uses the one the task names and asks when it doesn't say. The status line shows slack:acme+sidegig · supabase:personal.

What it can reach

❯ ./register.tsx hooks: session.start, tool.check, ui.render{component=Pane, requestId=accounts}, prompt.section{name=env_info_simple}, command.run{command=account} ❯ ./register.tsx calls: $.clock.after (via reload), $.command.register, $.command.run (via reload), $.fs.read (via sync), $.fs.write (via sync), $.mcp.connect (via list), $.session.root (via choose, inUse, list), $.store.get (via get), $.store.set (via add, choose, remove), $.ui.close, $.ui.invalidate (via choose), $.ui.log (via reload), $.ui.open (via picker), $.ui.resolve, $.ui.status (via status)

It writes one file, accounts.mcp.json in its own folder, and runs /reload-plugins only at a start when that file changed. Its store holds account names, the URLs of services you added, and each project root's picks. Sign-ins stay in Claude Code's own credential store.

Limits

  • claude.ai connectors can't be switched. Gmail, Calendar, Drive and the other claude.ai connectors belong to your claude.ai login. For several Google accounts, add a remote Google MCP server here by URL.
  • Adding or removing takes /reload-plugins. Claude Code refuses a reload started from inside a slash command, so the mod asks you to run it. Switching between accounts never needs it.
  • A server has to ignore the account query parameter. Context7, and the OAuth discovery of Slack and Supabase, were checked with it. A server that rejects unknown parameters won't work here.
  • Projects are keyed by the session's root, the folder the session started in (or /cd moved it to). A session started in a subfolder of a repo is its own project.
  • Removing an account keeps its sign-in. Claude Code keeps the stored token, and adding the same account again reuses it. To clear it, use /mcp → the server → Clear authentication before you remove it.

Uninstall

Pick each account's sign-in to clear in /mcp first if you want them gone, then:

/plugin uninstall mcp-accounts@claude-mods
Source 1 files
hooks/register.tsx 281 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Engine = EngineInterface
4/** What every account of a service is cloned from: its remote MCP server, and the OAuth client it signs in with when that is fixed. */
5type Service = { url: string; oauth?: { clientId: string; callbackPort: number } }
6/** Account names per service. */
7type Accounts = Record<string, string[]>
8/** Per project root, the accounts each service uses there. */
9type Active = Record<string, Record<string, string[]>>
10
11/** Copied from the official Slack and Supabase plugins; anything else is added by URL. */
12export const BUILT_IN: Record<string, Service> = {
13  slack: { url: 'https://mcp.slack.com/mcp', oauth: { clientId: '1601185624273.8899143856786', callbackPort: 3118 } },
14  supabase: { url: 'https://mcp.supabase.com/mcp' },
15}
16
17/** How the engine names this mod's servers' tools: `mcp__plugin_mcp-accounts_<service>-<account>__<tool>`. */
18const PREFIX = 'mcp__plugin_mcp-accounts_'
19/** Signing an account in acts on no workspace, so it is never blocked. */
20const SIGN_IN = new Set(['authenticate', 'complete_authentication'])
21const SERVICE = /^[a-z0-9]+$/
22const LABEL = /^[a-z0-9][a-z0-9-]{0,29}$/
23const VERBS = new Set(['add', 'remove'])
24const FILE = 'accounts.mcp.json'
25
26const USAGE = `/account                                  pick in a pane; also lists accounts, * on those this project uses
27/account <service> <account> [<account>…]  use those accounts in this project; the rest stay blocked
28/account add <service> <account> [url]    add an account (the URL for a service other than ${Object.keys(BUILT_IN).join(' or ')})
29/account remove <service> <account>       remove one`
30
31const get = async <T,>($: Engine, key: string, empty: T) => ((await $.store.get(key)) ?? empty) as T
32const services = async ($: Engine) => ({ ...BUILT_IN, ...(await get<Record<string, Service>>($, 'services', {})) })
33const and = (xs: readonly string[]) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
34const tools = (service: string, labels: readonly string[]) => labels.map(l => `${PREFIX}${service}-${l}__*`).join(' and ')
35
36/** The accounts a service uses in this project: the ones chosen here, else its only one. */
37async function inUse($: Engine, service: string) {
38  const labels = (await get<Accounts>($, 'accounts', {}))[service] ?? []
39  const chosen = [(await get<Active>($, 'active', {}))[await $.session.root()]?.[service] ?? []].flat().filter(l => labels.includes(l))
40  return chosen.length ? chosen : labels.length === 1 ? labels : []
41}
42
43/** One server per account. The URL carries the account, since the engine runs servers that share a URL as one, under one login. */
44export function servers(accounts: Accounts, known: Record<string, Service>) {
45  const mcpServers: Record<string, unknown> = {}
46  for (const service of Object.keys(accounts).sort()) {
47    const s = known[service]
48    if (!s) continue
49    for (const label of [...accounts[service]!].sort()) {
50      mcpServers[`${service}-${label}`] = { type: 'http', url: `${s.url}${s.url.includes('?') ? '&' : '?'}account=${label}`, ...(s.oauth && { oauth: s.oauth }) }
51    }
52  }
53  return { mcpServers }
54}
55
56/** Writes the manifest's server list from the stored accounts: true when it changed, and the engine has to reload it. */
57async function sync($: Engine) {
58  const text = `${JSON.stringify(servers(await get($, 'accounts', {}), await services($)), null, 2)}\n`
59  const path = `${$.plugin.root}/${FILE}`
60  if ((await $.fs.read(path).catch(() => '')) === text) return false
61  await $.fs.write(path, text)
62  return true
63}
64
65/** At a start only: run from a command's hook, the host refuses it while that hook holds the turn. */
66function reload($: Engine) {
67  $.clock.after(0, () => void $.command.run({ command: 'reload-plugins' }).catch(err => $.ui.log(`could not reload the account servers (${String(err)}): run /reload-plugins`)))
68}
69
70async function status($: Engine) {
71  const parts: string[] = []
72  for (const service of Object.keys(await get<Accounts>($, 'accounts', {})).sort()) parts.push(`${service}:${(await inUse($, service)).join('+') || '?'}`)
73  $.ui.status(parts.length ? parts.join(' · ') : undefined)
74}
75
76async function note($: Engine) {
77  const lines: string[] = []
78  for (const [service, labels] of Object.entries(await get<Accounts>($, 'accounts', {}))) {
79    const a = await inUse($, service)
80    lines.push(
81      a.length === 1
82        ? `${service} uses its ${a[0]} account in this project: the ${tools(service, a)} tools.`
83        : a.length
84          ? `${service} uses its ${and(a)} accounts in this project (the ${tools(service, a)} tools): use the one the task names, and ask the person when it doesn't say.`
85          : `${service} has several accounts (${labels.join(', ')}) and none is chosen for this project: ask the person which, and they pick with /account ${service} <account>.`,
86    )
87  }
88  return lines.length ? `MCP accounts (mcp-accounts): ${lines.join(' ')} A call to any other account's tools is refused; only the person changes which accounts are in use.` : ''
89}
90
91/** Why a call reaching one of this mod's servers (`slack-foo`) through tool `name` is refused, or undefined when it may go ahead. */
92async function refusal($: Engine, server: string, name: string) {
93  if (SIGN_IN.has(name)) return undefined
94  const cut = server.indexOf('-')
95  const service = server.slice(0, cut)
96  const label = server.slice(cut + 1)
97  const a = await inUse($, service)
98  if (cut > 0 && a.includes(label)) return undefined
99  return a.length
100    ? `This project uses ${service} ${and(a)}, not ${label}: use the ${tools(service, a)} tools, or ask the person to add ${label} with /account ${service} ${[...a, label].join(' ')}.`
101    : `No ${service} account is chosen for this project: ask the person which to use; they pick with /account ${service} <account>.`
102}
103
104/** The mod's server a call reaches: by its own tool name, or by the `server` the MCP resource tools take (`plugin:mcp-accounts:slack-foo`). */
105function target(tool: string, input: unknown) {
106  if (tool.startsWith(PREFIX)) {
107    const [server = '', name = ''] = tool.slice(PREFIX.length).split('__')
108    return { server, name }
109  }
110  const named = (input as { server?: unknown } | null)?.server
111  const m = typeof named === 'string' ? /^plugin[:_]mcp-accounts[:_](.+)$/.exec(named) : null
112  return m ? { server: m[1]!, name: '' } : undefined
113}
114
115/**
116 * A resource listing that names no server reaches every server, so it is always refused: whether one of this mod's
117 * is loaded for an account not in use can't be told reliably (a removed account's stays until the reload, and a
118 * server with resources but no tools leaves no trace in the tool list).
119 */
120const unnamed = (tool: string, input: unknown) => tool === 'ListMcpResourcesTool' && !(input as { server?: unknown } | null)?.server
121
122async function list($: Engine) {
123  const accounts = await get<Accounts>($, 'accounts', {})
124  const rows: string[] = []
125  for (const service of Object.keys(accounts).sort()) {
126    const a = await inUse($, service)
127    for (const label of accounts[service]!) {
128      const c = await $.mcp.connect(`${service}-${label}`)
129      const state = c.isConnected ? 'connected' : c.reason === 'auth' ? `sign in: /mcp → plugin:mcp-accounts:${service}-${label} → Authenticate` : c.message
130      rows.push(`${a.includes(label) ? '*' : ' '} ${service} · ${label}   ${state}`)
131    }
132  }
133  return rows.length ? `${rows.join('\n')}\n\nMarked *: in use in ${await $.session.root()}` : `No accounts yet.\n\n${USAGE}`
134}
135
136async function add($: Engine, service: string, label: string, url?: string) {
137  if (!SERVICE.test(service) || VERBS.has(service)) return `A service name is lowercase letters and digits, and not add or remove: "${service}" isn't one.`
138  if (!LABEL.test(label)) return `An account name is up to 30 lowercase letters, digits and dashes: "${label}" isn't one.`
139  const known = await services($)
140  if (url) {
141    let u: URL
142    try {
143      u = new URL(url)
144    } catch {
145      return `"${url}" isn't a URL.`
146    }
147    if (u.protocol !== 'https:') return 'The server URL has to be https.'
148    if (known[service] && known[service].url !== url) return `${service} already points at ${known[service].url}: add the account without a URL, or name a new service.`
149    if (!known[service]) await $.store.set('services', { ...(await get($, 'services', {})), [service]: { url } })
150  } else if (!known[service]) {
151    return `${service} is new: give its MCP server's URL with the first account, /account add ${service} ${label} https://…`
152  }
153  const accounts = await get<Accounts>($, 'accounts', {})
154  if (accounts[service]?.includes(label)) return `${service} · ${label} is already added.`
155  await $.store.set('accounts', { ...accounts, [service]: [...(accounts[service] ?? []), label] })
156  await sync($)
157  await status($)
158  return `Added ${service} · ${label}. Run /reload-plugins to load it, then sign it in: /mcp → plugin:mcp-accounts:${service}-${label} → Authenticate, and pick that workspace, or log in as that account, in the browser.`
159}
160
161async function remove($: Engine, service: string, label: string) {
162  const accounts = await get<Accounts>($, 'accounts', {})
163  const left = (accounts[service] ?? []).filter(l => l !== label)
164  if (left.length === (accounts[service] ?? []).length) return `There's no ${service} account named ${label}.`
165  const { [service]: _, ...others } = accounts
166  await $.store.set('accounts', left.length ? { ...accounts, [service]: left } : others)
167  // A service added by URL goes with its last account; adding it again takes the URL again.
168  if (!left.length) {
169    const { [service]: _gone, ...kept } = await get<Record<string, Service>>($, 'services', {})
170    await $.store.set('services', kept)
171  }
172  await sync($)
173  await status($)
174  return `Removed ${service} · ${label}; /reload-plugins drops its server. Claude Code keeps its stored sign-in, so adding the account again reuses it.`
175}
176
177/** Sets the accounts a service uses in this project; the project root it set them for. */
178async function choose($: Engine, service: string, labels: readonly string[]) {
179  const root = await $.session.root()
180  const all = await get<Active>($, 'active', {})
181  await $.store.set('active', { ...all, [root]: { ...all[root], [service]: [...new Set(labels)] } })
182  await status($)
183  // Claude's note names the accounts in use: it changes from the next request on.
184  $.ui.invalidate('prompt.section')
185  $.ui.invalidate('ui.render')
186  return root
187}
188
189async function use($: Engine, service: string, picked: readonly string[]) {
190  const labels = (await get<Accounts>($, 'accounts', {}))[service] ?? []
191  if (!labels.length) return `No ${service} accounts yet: /account add ${service} <account>.`
192  if (!picked.length) return `${service}: ${labels.join(', ')}. /account ${service} <account> [<account>…] picks for this project.`
193  const unknown = picked.filter(l => !labels.includes(l))
194  if (unknown.length) return `There's no ${service} account named ${and(unknown)}: ${labels.join(', ')}.`
195  return `${service} → ${and(picked)} in ${await choose($, service, picked)}.`
196}
197
198const PANE = 'accounts'
199
200async function picker($: Engine) {
201  const accounts = await get<Accounts>($, 'accounts', {})
202  const rows = Object.values(accounts).reduce((n, labels) => n + labels.length + 1, 2)
203  await $.ui.open({ id: PANE, title: 'MCP accounts', focus: true, closeOnEscape: true, rows })
204}
205
206export const register: Register = on => {
207  on('session.start', async ($, e, next) => {
208    await $.command.register({ name: 'account', description: 'Pick which logins an MCP service uses in this project, or add one', argumentHint: '[<service> <account>… | add <service> <account> [url] | remove <service> <account>]' })
209    if (await sync($)) reload($)
210    await status($)
211    return next(e)
212  })
213
214  // Refused, never waved through, when the check itself fails: the call could reach the wrong workspace.
215  on('tool.check', async ($, e, next) => {
216    if (unnamed(e.tool, e.input)) {
217      return { decision: 'deny', reason: "Name a server: mcp-accounts refuses a listing of every server's resources, which could include an MCP account this project doesn't use." }
218    }
219    const t = target(e.tool, e.input)
220    const reason = t && (await refusal($, t.server, t.name))
221    return reason ? { decision: 'deny', reason } : next(e)
222  }).catch(($, e, next) => (target(e.tool, e.input) ? { decision: 'deny', reason: 'mcp-accounts could not tell which account this project uses.' } : next(e)))
223
224  // The picker: each service, then a row per account, ● on those this project uses. The name uses that account alone
225  // and closes; beside it, + adds it to those in use and − drops it, the pane staying open.
226  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
227    const { Box, Text, Button } = $.ui.resolve(e)
228    const accounts = await get<Accounts>($, 'accounts', {})
229    const only = async (service: string, label: string) => {
230      await choose($, service, [label])
231      await $.ui.close({ id: PANE })
232    }
233    const toggle = async (service: string, label: string) => {
234      const a = await inUse($, service)
235      await choose($, service, a.includes(label) ? a.filter(l => l !== label) : [...a, label])
236    }
237    const rows = []
238    for (const service of Object.keys(accounts).sort()) {
239      const labels = accounts[service]!
240      const a = await inUse($, service)
241      const width = Math.max(...labels.map(l => l.length)) + 3
242      rows.push(<Text bold>{service}</Text>)
243      for (const label of labels) {
244        const used = a.includes(label)
245        rows.push(
246          <Box flexDirection="row">
247            <Box width={width}>
248              <Button key={`${service}-${label}`} plain label={`${used ? '●' : ' '} ${label}`} {...(used ? { autoFocus: true as const } : {})} onPress={() => only(service, label)} />
249            </Box>
250            {labels.length > 1 && <Button key={`${service}-${label}+`} plain dimColor label={used ? '−' : '+'} onPress={() => toggle(service, label)} />}
251          </Box>,
252        )
253      }
254    }
255    return (
256      <Box flexDirection="column">
257        {rows.length ? rows : <Text dimColor>No accounts yet: /account add {'<service> <account>'}</Text>}
258        <Text dimColor>Enter on a name uses only it · + adds, − drops · Esc closes</Text>
259      </Box>
260    )
261  })
262
263  on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
264    const r = await next(e)
265    const n = await note($)
266    return n ? { text: [r.text, n].filter(Boolean).join('\n\n') } : r
267  }).catch(($, e, next) => next(e))
268
269  on('command.run', { command: 'account' }, async ($, e) => {
270    const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
271    const [service = '', label = '', url] = rest
272    if (!verb) {
273      await picker($)
274      return { text: await list($) }
275    }
276    if (verb === 'add') return { text: service && label ? await add($, service, label, url) : USAGE }
277    if (verb === 'remove') return { text: service && label ? await remove($, service, label) : USAGE }
278    return { text: await use($, verb, rest) }
279  })
280}
281