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…

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.
/plugin install mcp-accounts --marketplace JesseVent/claude-mods
/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./reload-plugins. The new servers join the session with no restart./mcp → plugin:mcp-accounts:<service>-<account> → Authenticate. The browser opens on that service's sign-in.Claude Code stores each account's sign-in separately, and it lasts across sessions.
/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.
| Command | What it does |
|---|---|
/account | Opens 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.
?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./reload-plugins itself.ReadMcpResourceTool and the other resource tools, matched by the server they name)./account command that would add the blocked one.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.❯ ./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.
/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.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./cd moved it to). A session started in a subfolder of a repo is its own project./mcp → the server → Clear authentication before you remove it.Pick each account's sign-in to clear in /mcp first if you want them gone, then:
/plugin uninstall mcp-accounts@claude-modshooks/register.tsx 281 lines1import 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