claude-multiacc 2.0.38 → 2.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -395,6 +395,84 @@ an account, and if one slips through it returns to the pool by itself. Output is
395
395
  never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
396
396
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
397
397
 
398
+ ## MCP servers for every account
399
+
400
+ Both clients keep MCP servers in the **config dir** — `<dir>/.claude.json` for Claude Code,
401
+ `<CODEX_HOME>/config.toml` for Codex — so under the pool there is one MCP configuration per
402
+ account, and a stock `claude mcp add` lands in **one random account**. That is how
403
+ `appinspire-mcp` was connected in some sessions and missing in others (2026-09-22). The pool
404
+ now owns a **registry**, `<pool>/mcp-servers.json`, that is reconciled into every account:
405
+
406
+ ```bash
407
+ claude-accounts mcp add appinspire-mcp -- npx -y appinspire-mcp@latest serve # both pools
408
+ claude-accounts mcp add --provider claude local-dev -e KEY=v -- node /path/server.mjs serve
409
+ claude-accounts mcp add --scope project --project "$HOME/design-lab/run1" appinspire -e APPINSPIRE_LIBRARY_DIR="$HOME/.appinspire-mcp/library" -- node "$HOME/appinspire-mcp/bin/appinspire-mcp.mjs" serve
410
+ claude-accounts mcp remove adspower-local-api # retire EVERYWHERE: every account, every project entry
411
+ claude-accounts mcp remove foo --user-only # user scope only; project-local copies stay
412
+ claude-accounts mcp list [--json]
413
+ claude-accounts mcp apply # re-apply now (repair drift)
414
+ ```
415
+
416
+ - **Default `--provider both`**: an MCP server is not provider-specific, so `claude-accounts
417
+ mcp add` also writes the codex pool's registry (and `codex-accounts mcp add` the claude
418
+ one); `--provider claude|codex` narrows it, and an explicit provider whose pool is missing
419
+ on this machine is an error (the default is silent about it). `-e KEY=VAL` takes one
420
+ variable per flag; write `$HOME/...`, never `~/...`, inside a value (zsh does not expand
421
+ `~` after `=` in a word).
422
+ - **Tombstones**: an explicit `mcp remove NAME` retires the server **everywhere** — user
423
+ scope and every project entry, in every account, on every Mac after sync — even a copy
424
+ that was only ever hand-added to a single account (registry key `retired`). `--user-only`
425
+ records a user-scope-only tombstone (`retiredUser`). A stock removal mirrored from the shim
426
+ is scope-limited: `claude mcp remove -s user X` retires X in user scope only, a `-s local`
427
+ removal retires it in that one project only. Re-adding a name lifts its tombstone.
428
+ - **Exit status 3** from `mcp add|add-json|remove` means the registry was saved but some
429
+ account could not be reconciled (a corrupt `.claude.json`, a `config.toml` layout the
430
+ editor refuses): the change still syncs and still reaches the sibling pool; fix the account
431
+ and run `mcp apply`. Exit 1 means nothing was saved.
432
+ - **Where it is applied**: at seed time (`add`/`import`/`sync`/`post-sync`, so a new account or
433
+ a fleet Mac gets it), and by the **shim right before exec** — the picked account is
434
+ reconciled when the registry, the machine-local overlay or the account's own config changed
435
+ since the last verified reconcile (stamp in `<acct>/.mcp-applied`); Claude Code rewrites
436
+ `.claude.json` during most sessions, so on the claude side that check usually costs one
437
+ short python start-up per launch. Fail-open: a missing python or lib never breaks a launch.
438
+ - **Stock commands are mirrored**: `claude mcp add|add-json|remove` and `codex mcp add|remove`
439
+ run under the shim execute against the picked account as usual, and the shim then copies the
440
+ outcome into the registry and every other account (`learn`). This includes a command run
441
+ from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
442
+ an agent): the session's config dir is a pool account, so the same mirror applies. A
443
+ config dir outside the pool is plain passthrough.
444
+ - **Replicas**: on a pool whose `sync-role` file says `replica`, `add`/`remove` and the
445
+ shim's mirror land in the machine-local overlay (owner `local`) instead of the synced
446
+ registry, because the source's next push would overwrite them. Make registry changes on
447
+ the source machine.
448
+ - **Project scope is claude-only** (`--scope project --project PATH`, or a stock `-s local`
449
+ add): Codex 0.156 reads no per-project MCP config, so a project entry is applied to every
450
+ claude account's `projects[PATH]` and skipped for codex.
451
+ - **Fleet**: the **claude** registry rides with the manifest — `sync` pushes it to the server
452
+ and every manifest peer (all runner Macs), and the claude limits pass fans it out with the
453
+ telemetry, so a mirrored add reaches every Mac within one cadence; each target's
454
+ `post-sync` re-seeds and applies it. The **codex** registry reaches only codex sync
455
+ targets (the server, gas-mini): runner Macs' codex pools are local-only and get their
456
+ servers from app-robot's runner overlay (`codex-accounts mcp import-local --owner
457
+ app-robot`, app-robot PR #1870) or from a direct `codex-accounts mcp add --provider codex …`
458
+ on that Mac. The **machine-local overlay** `mcp-servers.local.json` is applied on its Mac
459
+ and **never synced**; the synced registry wins on a name both hold.
460
+ - **Codex translation**: a Claude-style block becomes `[mcp_servers.<name>]`; unrelated
461
+ tables, their comments and blank lines are left alone. `KEY=${KEY}` env references become
462
+ `env_vars` (codex forwards a variable under its own name only); `KEY=${OTHER}` and
463
+ `${VAR:-default}` cannot be expressed, so that variable is left out with a warning. A stdio
464
+ server gets `startup_timeout_sec = 60` unless the block sets one (a cold `npx` download must
465
+ not trip Codex's 10 s default); SSE transports are skipped; codex-only keys (`oauth`,
466
+ `enabled_tools`, …) ride along in the registry block and never reach `.claude.json`.
467
+ - **Kill switches**: `CLAUDE_MULTIACC_MCP=0` / `CODEX_MULTIACC_MCP=0` stop the reconcile at
468
+ seed and launch and the shim's mirror; `claude-accounts mcp …`, sync and the fan-out still
469
+ work.
470
+
471
+ app-robot tasks are different: the panel launches Claude with `--strict-mcp-config` and a
472
+ merged MCP document (`agent_mcp.servers_for`) and Codex with the same document as `-c`
473
+ overrides, so a server every task must see is added there — never with `claude mcp add` on a
474
+ Mac.
475
+
398
476
  ## Codex support (OpenAI Codex CLI)
399
477
 
400
478
  Both pools use the same selection policy. See [Codex account commands and behavior](docs/CODEX.md).
package/bin/claude CHANGED
@@ -71,11 +71,117 @@ REAL="$(find_real)" || {
71
71
  exit 127
72
72
  }
73
73
 
74
+ if [ "$(uname -s)" = "Darwin" ]; then
75
+ file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
76
+ file_sig() { stat -L -f '%m:%z' "$1" 2>/dev/null || echo '?'; }
77
+ else
78
+ file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
79
+ file_sig() { stat -L -c '%Y:%s' "$1" 2>/dev/null || echo '?'; }
80
+ fi
81
+
82
+ # ---- MCP servers for every account (lib/mcp_registry.py) -------------------------
83
+ # Claude Code keeps MCP servers in the CONFIG DIR (`<dir>/.claude.json`), so under the
84
+ # pool there is one MCP configuration per account — and a stock `claude mcp add` lands
85
+ # in ONE random one (2026-09-22: appinspire was connected in some sessions and not in
86
+ # others, depending on the account drawn). The pool's registry (mcp-servers.json, synced
87
+ # fleet-wide) plus a machine-local overlay (mcp-servers.local.json) are reconciled into
88
+ # the picked account right before exec, so a session gets the same servers whichever
89
+ # account it drew. Bash only on the hot path: python runs when the registry, the overlay
90
+ # or the account's own config changed since the last verified reconcile — the stamp in
91
+ # <dir>/.mcp-applied — and never otherwise. Fail-open throughout: a missing python, a
92
+ # missing lib or a broken registry costs nothing but the servers themselves.
93
+ MCP_PY="$SELF_DIR/../lib/mcp_registry.py"
94
+ MCP_PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
95
+ mcp_sig() { # $1 acct dir -> "m:z,m:z,m:z" (registry, overlay, account config; - = absent)
96
+ local f out="" s
97
+ for f in "$ACC_ROOT/mcp-servers.json" "$ACC_ROOT/mcp-servers.local.json" "$1/.claude.json"; do
98
+ if [ -f "$f" ]; then s="$(file_sig "$f")"; else s="-"; fi
99
+ out="$out${out:+,}$s"
100
+ done
101
+ printf '%s\n' "$out"
102
+ }
103
+ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
104
+ [ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 0
105
+ [ -f "$ACC_ROOT/mcp-servers.json" ] || [ -f "$ACC_ROOT/mcp-servers.local.json" ] || return 0
106
+ [ -f "$MCP_PY" ] || return 0
107
+ local stamp=""
108
+ [ -f "$1/.mcp-applied" ] && IFS= read -r stamp < "$1/.mcp-applied" 2>/dev/null
109
+ [ "${stamp:-}" = "$(mcp_sig "$1")" ] && return 0
110
+ "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude apply --account-dir "$1" \
111
+ --fail-open --quiet >/dev/null 2>&1 </dev/null || true
112
+ return 0
113
+ }
114
+
115
+ # `claude mcp add|add-json|remove` writes ONE account's .claude.json — the one it ran
116
+ # under — and nobody else's. So the shim runs it as a child instead of exec-ing it, then
117
+ # mirrors what it wrote into the registry and every other account (`learn` diffs the
118
+ # account against a snapshot taken just before). The real CLI's own parser and writer do
119
+ # the work; the shim only copies the outcome. Exits with the real binary's status; a
120
+ # mirror failure is one hint line. Returns 1 when this run is not such a command.
121
+ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
122
+ local d="$1" snap="" rc verb="${2:-}" sub="${3:-}"
123
+ shift
124
+ # The scoped-limit fallback may have prepended `--model X` (a global option the real
125
+ # CLI accepts in front of any subcommand): look past it.
126
+ if [ "$verb" = "--model" ]; then verb="${3:-}"; sub="${4:-}"; fi
127
+ [ "$verb" = "mcp" ] || return 1
128
+ case "$sub" in add|add-json|remove) ;; *) return 1 ;; esac
129
+ [ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
130
+ mcp_ensure "$d"
131
+ snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
132
+ if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude snapshot \
133
+ --account-dir "$d" > "$snap" 2>/dev/null </dev/null; then
134
+ rm -f "$snap" 2>/dev/null; snap=""
135
+ fi
136
+ "$REAL" "$@"
137
+ rc=$?
138
+ if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
139
+ "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude learn --account-dir "$d" \
140
+ --before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" </dev/null \
141
+ || printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
142
+ elif [ "$rc" -eq 0 ]; then
143
+ printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
144
+ fi
145
+ [ -n "$snap" ] && rm -f "$snap" 2>/dev/null
146
+ exit "$rc"
147
+ }
148
+
149
+ # A pooled SESSION exports CLAUDE_CONFIG_DIR=<acct> and CLAUDE_SHIM_ACTIVE=1, and every child
150
+ # process inherits both — so a `claude mcp add` run from INSIDE a session (an agent's
151
+ # Bash tool, `npx appinspire-mcp install` run by an agent) arrives at the fast passthrough
152
+ # below with the account already chosen, not at the selection path where the mirror
153
+ # lives. That is the very write the 2026-09-22 outage was made of, in the place MCP
154
+ # servers are most often installed. The account is known, so the same mirror applies:
155
+ # exits inside when it handled the run; anything else stays byte-identical passthrough.
156
+ mcp_nested_mirror() {
157
+ [ -f "$MANIFEST" ] || return 1
158
+ [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] && return 1
159
+ [ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 1
160
+ [ -n "${CLAUDE_CONFIG_DIR:-}" ] || return 1
161
+ local verb="${1:-}" cfg="${CLAUDE_CONFIG_DIR%/}" d=""
162
+ if [ "$verb" = "--model" ]; then verb="${3:-}"; fi
163
+ [ "$verb" = "mcp" ] || return 1
164
+ # A DIRECT child of the pool root, by its given spelling or its physical one (an
165
+ # adopted account is a symlink whose target is the operator's ~/.claude).
166
+ case "$cfg" in
167
+ "$ACC_ROOT"/acct-*) case "${cfg#"$ACC_ROOT"/}" in */*) ;; *) d="$cfg" ;; esac ;;
168
+ esac
169
+ if [ -z "$d" ]; then
170
+ local root_c; root_c="$(canon_path "$ACC_ROOT")"
171
+ case "$(canon_path "$cfg")" in
172
+ "$root_c"/acct-*) case "${cfg##*/}" in acct-*) d="$cfg" ;; esac ;;
173
+ esac
174
+ fi
175
+ [ -n "$d" ] && [ -d "$d" ] || return 1
176
+ mcp_mirror_run "$d" "$@"
177
+ }
178
+
74
179
  # Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
75
180
  # guard, or no account data yet. Byte-identical behavior to stock claude.
76
181
  if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
77
182
  || [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
78
183
  || [ ! -f "$MANIFEST" ]; then
184
+ mcp_nested_mirror "$@" || true
79
185
  exec "$REAL" "$@"
80
186
  fi
81
187
 
@@ -92,12 +198,6 @@ fi
92
198
  # complaining to stderr.
93
199
  case "$CLAUDE_MULTIACC_THRESHOLD" in ''|*[!0-9]*|??????*) CLAUDE_MULTIACC_THRESHOLD=90 ;; esac
94
200
 
95
- if [ "$(uname -s)" = "Darwin" ]; then
96
- file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
97
- else
98
- file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
99
- fi
100
-
101
201
  # ---- macOS Keychain-held logins ----------------------------------------------------
102
202
  # Claude Code on macOS moves a config dir's OAuth login into the login Keychain
103
203
  # (service "Claude Code-credentials-<sha256(dir)[:8]>") the first time a GUI-session
@@ -1232,6 +1332,10 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1232
1332
  fi
1233
1333
  export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1234
1334
  fi
1335
+ # A pinned account carries every registered MCP server too, and a pinned
1336
+ # `claude mcp add|remove` is mirrored like any other (exits inside).
1337
+ mcp_ensure "$d"
1338
+ mcp_mirror_run "$d" "$@" || true
1235
1339
  sel_capture_session "$d"
1236
1340
  exec "$REAL" "$@"
1237
1341
  fi
@@ -1572,6 +1676,8 @@ fi
1572
1676
  # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
1573
1677
  # not a turn in the rotation.
1574
1678
  remember_pick "$pick"
1679
+ # The picked account carries every registered MCP server before the client starts.
1680
+ mcp_ensure "$pick"
1575
1681
 
1576
1682
  # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
1577
1683
  # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
@@ -1648,6 +1754,15 @@ fi
1648
1754
 
1649
1755
  export CLAUDE_SHIM_ACTIVE=1
1650
1756
 
1757
+ # A `claude mcp add|add-json|remove` is run as a child under the picked account and
1758
+ # mirrored to every other one (exits inside); anything else falls through to exec.
1759
+ if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
1760
+ export CLAUDE_CONFIG_DIR="$pick"
1761
+ tok="$(acct_token "$pick")"
1762
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
1763
+ mcp_mirror_run "$pick" "$@" || true
1764
+ fi
1765
+
1651
1766
  # Auto-retry applies only to -p/--print runs with an alternative account available,
1652
1767
  # and only when stdin is finite (tty, regular file, or char device like /dev/null).
1653
1768
  # A service-spawned pipe that never EOFs must take the plain exec path, or the
@@ -1878,6 +1993,7 @@ while :; do
1878
1993
  # The account that actually serves the work is the one the next run should rotate
1879
1994
  # away from — not the one that bounced.
1880
1995
  remember_pick "$cur"
1996
+ mcp_ensure "$cur"
1881
1997
  rotated=1
1882
1998
  attempt=2
1883
1999
  continue
@@ -14,6 +14,12 @@ while [ -L "$_self" ]; do
14
14
  done
15
15
  BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
16
16
  REPO_DIR="$(dirname "$BIN_DIR")"
17
+ # Pin the provider here, the way codex-accounts pins codex: lib/common.sh defaults to
18
+ # claude only when the variable is UNSET, and codex-accounts exports it — so a
19
+ # claude-accounts started by codex-accounts (the `mcp --provider both` hop) inherited
20
+ # `codex`, became a second codex CLI, hopped to "its sibling" claude-accounts again, and
21
+ # forked itself to death ("fork: Resource temporarily unavailable").
22
+ export MULTIACC_PROVIDER=claude
17
23
  # shellcheck source=lib/common.sh
18
24
  . "$REPO_DIR/lib/common.sh"
19
25
 
@@ -93,6 +99,30 @@ USAGE
93
99
  claude-accounts health limits + full verify + shim-on-PATH probe; logs to health.log
94
100
  claude-accounts self-update update this npm/git install; logs to update.log
95
101
  claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
102
+ claude-accounts mcp add <name> [--provider claude|codex|both] [--scope user|project]
103
+ [--project PATH] [-e KEY=VAL]... [-H 'Header: v']...
104
+ [--transport stdio|http] -- <command> [args...] (or a URL)
105
+ register an MCP server for EVERY account: writes the pool's mcp-servers.json
106
+ registry, reconciles every account dir, syncs the registry to the server and
107
+ peers (their post-sync applies it). Default --provider both: the same server is
108
+ registered in the codex pool too (MCP servers are not provider-specific).
109
+ A stock `claude mcp add|add-json|remove` run under the shim is mirrored into
110
+ the registry the same way — it no longer lands in one random account.
111
+ -e takes ONE variable per flag (KEY=VAL; write $HOME/..., not ~/..., in a value).
112
+ Exit 3 = registry saved, but an account could not be reconciled (it is named on
113
+ stderr): the change still syncs; fix the account and run `mcp apply`.
114
+ On a replica pool the change lands in the machine-local overlay instead of the
115
+ synced registry — make registry changes on the source machine.
116
+ claude-accounts mcp add-json <name> '<json>' [--provider ...] [--scope ...]
117
+ claude-accounts mcp remove <name> [--provider ...] [--user-only] [--scope project --project PATH]
118
+ unregister AND retire EVERYWHERE: every account (fleet-wide after sync) drops the
119
+ server from user scope and from every project entry, even one that was hand-added
120
+ to a single account. --user-only spares project-local copies.
121
+ claude-accounts mcp list [--json] effective registry (+ machine-local overlay)
122
+ claude-accounts mcp apply [--account-dir DIR]... [--all]
123
+ re-apply the registry now (repair drift)
124
+ claude-accounts mcp import-local --owner NAME (stdin JSON) machine-local overlay for
125
+ a runner daemon; applied here, never synced
96
126
 
97
127
  ENV
98
128
  CLAUDE_ACCOUNTS_ROOT pool root, overriding ~/.claude-accounts — one isolated pool
@@ -105,6 +135,7 @@ ENV
105
135
  CLAUDE_SHIM_RETRY=0 disable -p auto-retry
106
136
  CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
107
137
  CLAUDE_MULTIACC_AUTO_RESET=0 disable automatic limit-reset redemption
138
+ CLAUDE_MULTIACC_MCP=0 disable the MCP registry (no reconcile at seed/launch, no mirror)
108
139
  CLAUDE_MULTIACC_RESET_MIN_HORIZON a reset is held while every park lifts on
109
140
  its own within this many seconds (default 3600)
110
141
  EOF
@@ -1541,6 +1572,10 @@ limits_distribute_now() {
1541
1572
  # never grants any. Erring toward exclusion is the safe direction here.
1542
1573
  [ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
1543
1574
  done
1575
+ # The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
1576
+ # add` reaches every peer within one limits cadence, with no explicit sync. The
1577
+ # machine-local overlay stays home (see sync_push_target).
1578
+ [ -f "$ACC_ROOT/mcp-servers.json" ] && printf 'mcp-servers.json\n' >> "$list"
1544
1579
  if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
1545
1580
 
1546
1581
  local pushed=0 failed=0
@@ -2893,6 +2928,14 @@ sync_push_target() {
2893
2928
  || fail "cannot reach $server"
2894
2929
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
2895
2930
  || fail "manifest push to $server failed"
2931
+ # The pool's MCP registry rides with the manifest; the target's post-sync reconciles it
2932
+ # into every account dir there. A missing local registry leaves the target's alone,
2933
+ # and the machine-local overlay (mcp-servers.local.json, a runner daemon's own view
2934
+ # of THIS Mac) never travels.
2935
+ if [ -f "$ACC_ROOT/mcp-servers.json" ]; then
2936
+ rsync -az "$ACC_ROOT/mcp-servers.json" "$server:$sroot/mcp-servers.json" >>"$ACC_ROOT/sync.log" 2>&1 \
2937
+ || fail "MCP registry push to $server failed"
2938
+ fi
2896
2939
 
2897
2940
  local id d
2898
2941
  for id in $(account_ids); do
@@ -3079,6 +3122,97 @@ EOF
3079
3122
  fi
3080
3123
  }
3081
3124
 
3125
+ # MCP servers for EVERY account (lib/mcp_registry.py). `add`/`add-json`/`remove` edit
3126
+ # this pool's registry under the mutate lock, reconcile every account, auto-sync, and
3127
+ # by default (--provider both) make the same change in the sibling provider's pool —
3128
+ # an MCP server is not provider-specific, and the operator wants it in both CLIs.
3129
+ # `list`/`apply`/`import-local` act on this pool alone; the overlay `import-local`
3130
+ # writes is machine-local by definition, so it is applied here and never synced.
3131
+ mcp_sibling_root() { # the other provider's pool root, by the same env precedence
3132
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then
3133
+ printf '%s\n' "${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"
3134
+ else
3135
+ printf '%s\n' "${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
3136
+ fi
3137
+ }
3138
+ cmd_mcp() {
3139
+ require_manifest
3140
+ local sub="${1:-}" provider=both before_dd=1 a rc=0 sibling sibling_root
3141
+ local args=()
3142
+ case "$sub" in
3143
+ add|add-json|remove|list|apply|import-local) shift ;;
3144
+ "") usage; return 1 ;;
3145
+ *) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local)" ;;
3146
+ esac
3147
+ [ -f "$LIB_DIR/mcp_registry.py" ] || die "lib/mcp_registry.py is missing from this install"
3148
+ case "$sub" in
3149
+ list|apply|import-local)
3150
+ "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" "$sub" "$@"
3151
+ return $? ;;
3152
+ esac
3153
+ # --provider is OUR option: strip it, but only before a literal `--` — after it every
3154
+ # word belongs to the MCP server's own command line and is passed through untouched.
3155
+ while [ $# -gt 0 ]; do
3156
+ a="$1"
3157
+ if [ "$before_dd" = 1 ]; then
3158
+ case "$a" in
3159
+ --) before_dd=0 ;;
3160
+ --provider)
3161
+ [ $# -ge 2 ] || die "--provider needs a value: claude, codex or both"
3162
+ provider="$2"; shift 2; continue ;;
3163
+ --provider=*) provider="${a#--provider=}"; shift; continue ;;
3164
+ esac
3165
+ fi
3166
+ args+=("$a"); shift
3167
+ done
3168
+ case "$provider" in
3169
+ claude|codex|both) ;;
3170
+ *) die "--provider must be claude, codex or both (got '$provider')" ;;
3171
+ esac
3172
+ if [ "$provider" = "$MULTIACC_PROVIDER" ] || [ "$provider" = both ]; then
3173
+ mutate_lock || die "could not acquire the account lock — try again"
3174
+ trap mutate_unlock EXIT
3175
+ "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" "$sub" \
3176
+ ${args[@]+"${args[@]}"}
3177
+ rc=$?
3178
+ mutate_unlock
3179
+ trap - EXIT
3180
+ # 3 = the registry WAS saved and some account could not be reconciled (a corrupt
3181
+ # config, a layout the TOML editor refuses): the change still has to reach the
3182
+ # server, the peers and the sibling pool, and `mcp apply` repairs the account once
3183
+ # it is fixed. 1/2 = nothing was saved: stop here.
3184
+ case "$rc" in
3185
+ 0) ;;
3186
+ 3) printf '%s: registry saved; some accounts were not reconciled — run: %s mcp apply\n' \
3187
+ "$PROVIDER_CLI" "$PROVIDER_CLI" >&2 ;;
3188
+ *) return "$rc" ;;
3189
+ esac
3190
+ auto_sync
3191
+ fi
3192
+ if [ "$provider" != "$MULTIACC_PROVIDER" ]; then
3193
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then sibling=claude; else sibling=codex; fi
3194
+ sibling_root="$(mcp_sibling_root)"
3195
+ # With the DEFAULT (both), a machine without the sibling pool or CLI has nothing to
3196
+ # mirror, and silently so — a codex-only Mac must not warn on every claude
3197
+ # change. An EXPLICIT --provider codex asked for that pool by name: refuse loudly.
3198
+ if [ ! -f "$sibling_root/accounts.json" ]; then
3199
+ [ "$provider" = both ] && return "$rc"
3200
+ die "no $sibling pool on this machine ($sibling_root/accounts.json is missing)"
3201
+ fi
3202
+ if [ ! -x "$BIN_DIR/$sibling-accounts" ]; then
3203
+ [ "$provider" = both ] && return "$rc"
3204
+ die "$sibling-accounts is not installed beside $PROVIDER_CLI"
3205
+ fi
3206
+ # The caller's no-sync request travels with the hop, in the sibling's spelling.
3207
+ if [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ]; then export CODEX_MULTIACC_NO_SYNC=1; fi
3208
+ local sib_rc=0
3209
+ MULTIACC_PROVIDER="$sibling" "$BIN_DIR/$sibling-accounts" mcp "$sub" --provider "$sibling" \
3210
+ ${args[@]+"${args[@]}"} || sib_rc=$?
3211
+ [ "$rc" -eq 0 ] && rc="$sib_rc"
3212
+ fi
3213
+ return "$rc"
3214
+ }
3215
+
3082
3216
  cmd_post_sync() {
3083
3217
  require_manifest
3084
3218
  local_pool_fixup
@@ -3194,11 +3328,13 @@ $shim_out"
3194
3328
  # distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
3195
3329
  # FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
3196
3330
  # the dispatcher below implements, and a test pins that both ways.
3197
- _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update"
3331
+ _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp"
3198
3332
  case " $_KNOWN_VERBS " in
3199
3333
  *" ${1:-help} "*)
3334
+ # ...but never past a literal `--`: from there on the words belong to an MCP
3335
+ # server's own command line (`mcp add … -- node s.js -h 127.0.0.1`).
3200
3336
  for _arg in "$@"; do
3201
- case "$_arg" in --help|-h) usage; exit 0 ;; esac
3337
+ case "$_arg" in --) break ;; --help|-h) usage; exit 0 ;; esac
3202
3338
  done ;;
3203
3339
  esac
3204
3340
 
@@ -3224,6 +3360,7 @@ case "${1:-help}" in
3224
3360
  post-sync) shift; cmd_post_sync "$@" ;;
3225
3361
  health) shift; cmd_health "$@" ;;
3226
3362
  self-update) shift; cmd_self_update "$@" ;;
3363
+ mcp) shift; cmd_mcp "$@" ;;
3227
3364
  help|--help|-h) usage ;;
3228
3365
  *) usage; exit 1 ;;
3229
3366
  esac
package/bin/codex CHANGED
@@ -73,11 +73,112 @@ REAL="$(find_real)" || {
73
73
  exit 127
74
74
  }
75
75
 
76
+ if [ "$(uname -s)" = "Darwin" ]; then
77
+ file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
78
+ file_sig() { stat -L -f '%m:%z' "$1" 2>/dev/null || echo '?'; }
79
+ else
80
+ file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
81
+ file_sig() { stat -L -c '%Y:%s' "$1" 2>/dev/null || echo '?'; }
82
+ fi
83
+
84
+ # ---- MCP servers for every account (lib/mcp_registry.py) -------------------------
85
+ # Codex keeps MCP servers in CODEX_HOME (`<dir>/config.toml`), so under the pool there
86
+ # is one MCP configuration per account — and a stock `codex mcp add` lands in ONE random
87
+ # one. The pool's registry (mcp-servers.json, synced) plus a machine-local overlay
88
+ # (mcp-servers.local.json) are reconciled into the picked account right before exec, so
89
+ # a session gets the same servers whichever account it drew. Bash only on the hot path:
90
+ # python runs when the registry, the overlay or the account's own config changed since
91
+ # the last verified reconcile — the stamp in <dir>/.mcp-applied — and never otherwise.
92
+ # Fail-open throughout: a missing python, lib or a broken registry costs nothing but
93
+ # the servers themselves. Same contract as bin/claude's mcp_ensure.
94
+ MCP_PY="$SELF_DIR/../lib/mcp_registry.py"
95
+ MCP_PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
96
+ mcp_sig() { # $1 acct dir -> "m:z,m:z,m:z" (registry, overlay, account config; - = absent)
97
+ local f out="" s
98
+ for f in "$ACC_ROOT/mcp-servers.json" "$ACC_ROOT/mcp-servers.local.json" "$1/config.toml"; do
99
+ if [ -f "$f" ]; then s="$(file_sig "$f")"; else s="-"; fi
100
+ out="$out${out:+,}$s"
101
+ done
102
+ printf '%s\n' "$out"
103
+ }
104
+ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
105
+ [ "${CODEX_MULTIACC_MCP:-1}" = "0" ] && return 0
106
+ [ -f "$ACC_ROOT/mcp-servers.json" ] || [ -f "$ACC_ROOT/mcp-servers.local.json" ] || return 0
107
+ [ -f "$MCP_PY" ] || return 0
108
+ local stamp=""
109
+ [ -f "$1/.mcp-applied" ] && IFS= read -r stamp < "$1/.mcp-applied" 2>/dev/null
110
+ [ "${stamp:-}" = "$(mcp_sig "$1")" ] && return 0
111
+ "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex apply --account-dir "$1" \
112
+ --fail-open --quiet >/dev/null 2>&1 </dev/null || true
113
+ return 0
114
+ }
115
+
116
+ # `codex mcp add|remove` writes ONE account's config.toml — the one it ran under — and
117
+ # nobody else's. So the shim runs it as a child instead of exec-ing it, then mirrors what
118
+ # it wrote into the registry and every other account (`learn` diffs the account against a
119
+ # snapshot taken just before). The real CLI's own parser and writer do the work; the shim
120
+ # only copies the outcome. Exits with the real binary's status; a mirror failure is one
121
+ # hint line. Returns 1 when this run is not such a command.
122
+ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
123
+ local d="$1" snap="" rc
124
+ shift
125
+ [ "${1:-}" = "mcp" ] || return 1
126
+ case "${2:-}" in add|remove) ;; *) return 1 ;; esac
127
+ [ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
128
+ mcp_ensure "$d"
129
+ snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
130
+ if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex snapshot \
131
+ --account-dir "$d" > "$snap" 2>/dev/null </dev/null; then
132
+ rm -f "$snap" 2>/dev/null; snap=""
133
+ fi
134
+ "$REAL" "$@"
135
+ rc=$?
136
+ if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
137
+ "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex learn --account-dir "$d" \
138
+ --before "$snap" </dev/null \
139
+ || printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
140
+ elif [ "$rc" -eq 0 ]; then
141
+ printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
142
+ fi
143
+ [ -n "$snap" ] && rm -f "$snap" 2>/dev/null
144
+ exit "$rc"
145
+ }
146
+
147
+ # A pooled SESSION exports CODEX_HOME=<acct> and CODEX_SHIM_ACTIVE=1, and every child
148
+ # process inherits both — so a `codex mcp add` run from INSIDE a session (an agent's
149
+ # Bash tool, `npx appinspire-mcp install` run by an agent) arrives at the fast passthrough
150
+ # below with the account already chosen, not at the selection path where the mirror
151
+ # lives. That is the very write the 2026-09-22 outage was made of, in the place MCP
152
+ # servers are most often installed. The account is known, so the same mirror applies:
153
+ # exits inside when it handled the run; anything else stays byte-identical passthrough.
154
+ mcp_nested_mirror() {
155
+ [ -f "$MANIFEST" ] || return 1
156
+ [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] && return 1
157
+ [ "${CODEX_MULTIACC_MCP:-1}" = "0" ] && return 1
158
+ [ -n "${CODEX_HOME:-}" ] || return 1
159
+ local verb="${1:-}" cfg="${CODEX_HOME%/}" d=""
160
+ [ "$verb" = "mcp" ] || return 1
161
+ # A DIRECT child of the pool root, by its given spelling or its physical one (an
162
+ # adopted account is a symlink whose target is the operator's ~/.claude).
163
+ case "$cfg" in
164
+ "$ACC_ROOT"/acct-*) case "${cfg#"$ACC_ROOT"/}" in */*) ;; *) d="$cfg" ;; esac ;;
165
+ esac
166
+ if [ -z "$d" ]; then
167
+ local root_c; root_c="$(canon_path "$ACC_ROOT")"
168
+ case "$(canon_path "$cfg")" in
169
+ "$root_c"/acct-*) case "${cfg##*/}" in acct-*) d="$cfg" ;; esac ;;
170
+ esac
171
+ fi
172
+ [ -n "$d" ] && [ -d "$d" ] || return 1
173
+ mcp_mirror_run "$d" "$@"
174
+ }
175
+
76
176
  # Fast passthrough: caller pinned a config dir, addon disabled, recursion guard, or
77
177
  # no account data yet. Byte-identical behavior to stock codex.
78
178
  if [ -n "${CODEX_HOME:-}" ] \
79
179
  || [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CODEX_SHIM_ACTIVE:-}" ] \
80
180
  || [ ! -f "$MANIFEST" ]; then
181
+ mcp_nested_mirror "$@" || true
81
182
  exec "$REAL" "$@"
82
183
  fi
83
184
 
@@ -94,13 +195,6 @@ fi
94
195
  # complaining to stderr.
95
196
  case "$CODEX_MULTIACC_THRESHOLD" in ''|*[!0-9]*|??????*) CODEX_MULTIACC_THRESHOLD=90 ;; esac
96
197
 
97
- if [ "$(uname -s)" = "Darwin" ]; then
98
- file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
99
- else
100
- file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
101
- fi
102
-
103
-
104
198
  # A number this shim will do ARITHMETIC on: digits only, and short enough that bash
105
199
  # cannot go out of range. An over-range value makes `[ x -lt y ]` print
106
200
  # "integer expression expected" on stderr — which a service-spawned run must never see —
@@ -645,6 +739,10 @@ if [ -n "${CODEX_ACCOUNT:-}" ]; then
645
739
  share_state_index "$d"
646
740
  export CODEX_HOME="$d"
647
741
  export CODEX_SHIM_ACTIVE=1
742
+ # A pinned account carries every registered MCP server too, and a pinned
743
+ # `codex mcp add|remove` is mirrored like any other (exits inside).
744
+ mcp_ensure "$d"
745
+ mcp_mirror_run "$d" "$@" || true
648
746
  exec "$REAL" "$@"
649
747
  fi
650
748
  sel_log "pin-invalid account=$CODEX_ACCOUNT (no such dir; random fallback)"
@@ -874,6 +972,8 @@ pick="$PICK_DIR"
874
972
  # CODEX_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
875
973
  # not a turn in the rotation.
876
974
  remember_pick "$pick"
975
+ # The picked account carries every registered MCP server before the client starts.
976
+ mcp_ensure "$pick"
877
977
 
878
978
  # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
879
979
  # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
@@ -902,6 +1002,14 @@ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')%" \
902
1002
 
903
1003
  export CODEX_SHIM_ACTIVE=1
904
1004
 
1005
+ # A `codex mcp add|remove` is run as a child under the picked account and mirrored to
1006
+ # every other one (exits inside); anything else falls through to exec.
1007
+ if [ "${1:-}" = "mcp" ]; then
1008
+ share_state_index "$pick"
1009
+ export CODEX_HOME="$pick"
1010
+ mcp_mirror_run "$pick" "$@" || true
1011
+ fi
1012
+
905
1013
  # Auto-retry applies only to `codex exec` runs with an alternative account available,
906
1014
  # and only when stdin is finite (tty, regular file, or char device like /dev/null).
907
1015
  # A service-spawned pipe that never EOFs must take the plain exec path, or the
@@ -1033,6 +1141,7 @@ while :; do
1033
1141
  # The account that actually serves the work is the one the next run should rotate
1034
1142
  # away from — not the one that bounced.
1035
1143
  remember_pick "$cur"
1144
+ mcp_ensure "$cur"
1036
1145
  attempt=2
1037
1146
  continue
1038
1147
  fi