diff --git a/CHANGELOG.md b/CHANGELOG.md index a14164b..1bb9eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ccswitch mcp isolate ` gives an account its own MCP sign-ins, for + when it signs in to a server as someone else (a client's Figma, say). + `ccswitch mcp share ` puts it back on the shared ones, and + `ccswitch mcp` shows which accounts use which. + +### Fixed + +- Switching accounts no longer signs you out of OAuth MCP servers (Figma, + Sentry, PostHog...). Claude Code keeps their tokens in the same credential as + the Claude login, so each account restored its own stale copy and every + switch forced a fresh MCP sign-in. MCP tokens now live in one pool shared by + every account, and a switch swaps only the Claude login. Signing out of a + server sticks across switches. The first run gathers the tokens every stored + account already holds, keeping the newest copy of each. + ## [0.8.2] - 2026-10-17 ### Fixed diff --git a/README.md b/README.md index de2b9bb..70f754e 100644 --- a/README.md +++ b/README.md @@ -247,10 +247,21 @@ ccswitch stores per account, under `~/.config/ccswitch/accounts//`: ``` credentials.json copy of .credentials.json identity.json only the oauthAccount and userID keys from .claude.json +mcp-isolated present only for an account with its own MCP sign-ins ``` Switching writes those two back into place and leaves every other key in `~/.claude.json` untouched — which is why `mcpServers`, `projects` and the rest stay shared. +The credential file also holds the OAuth tokens of your MCP servers (`mcpOAuth`). Those belong to your Figma, Sentry... sign-in, not to the Claude account, so they are kept once, in `~/.config/ccswitch/mcp-oauth.json`, and shared by every account: a switch swaps only the Claude login. Sign in or out of an MCP server under any account and every account sees it. Connectors added through claude.ai live on Anthropic's side and stay per account. + +If one account should sign in to a server as someone else — a client's Figma, say — isolate it. It then keeps its own MCP sign-ins in its slot, and the shared ones stay untouched for everyone else: + +```bash +ccswitch mcp isolate client # client signs in to MCP servers on its own +ccswitch mcp share client # back to the shared sign-ins +ccswitch mcp # which accounts share, which are isolated +``` + Before switching away, ccswitch saves the live credential back into the account you're leaving. Claude Code refreshes tokens during a session, so without this you'd restore a stale token the next time around. ## Notes and caveats diff --git a/bin/ccswitch b/bin/ccswitch index 2efdeb2..ebd8e89 100755 --- a/bin/ccswitch +++ b/bin/ccswitch @@ -289,6 +289,123 @@ claude_running() { [ -n "$(claude_session_pids)" ] } +# --- MCP server tokens -------------------------------------------------------- +# Claude Code keeps the OAuth tokens of MCP servers (mcpOAuth) in the same blob +# as the Claude login, but they belong to your Figma, Sentry... sign-in, not to +# the Claude account. Restoring each account's own copy hands back tokens a +# renewal has since consumed, forcing a fresh MCP sign-in after every switch. +# So they live in one pool shared by every account, and a switch swaps only the +# Claude login. An isolated account keeps its own in its slot instead, for when +# it really does sign in to a server as someone else. +MCP_POOL="$VAULT/mcp-oauth.json" + +mcp_isolated() { [ -f "$ACCOUNTS/$1/mcp-isolated" ]; } + +# Build the pool on first run from what the vault already holds: every shared +# account's stored copy plus the live one, keeping the newest copy of each +# server, then give the live credential the result. +mcp_pool_init() { + [ -f "$MCP_POOL" ] && return 0 + local live="" cur d n; cur="$(active_name)" + local -a srcs=() + for d in "$ACCOUNTS"/*/; do + [ -d "$d" ] || continue + n="$(basename "$d")" + mcp_isolated "$n" || srcs+=("$d/credentials.json") + done + if cred_exists && ! { [ -n "$cur" ] && mcp_isolated "$cur"; }; then + live="$(mktemp)" + cred_read > "$live" 2>/dev/null || true + srcs+=("$live") + fi + python3 - "$MCP_POOL" ${srcs[@]+"${srcs[@]}"} <<'PYEOF' +import json, os, sys +def exp(e): + return (e.get("expiresAt") or 0) if isinstance(e, dict) else 0 +pool = {} +# Later sources win ties, and the live credential comes last. +for p in sys.argv[2:]: + try: + m = json.load(open(p)).get("mcpOAuth") or {} + except Exception: + continue + if not isinstance(m, dict): + continue + for k, v in m.items(): + a, b = exp(v), exp(pool.get(k)) + if k not in pool or not (a and b) or a >= b: + pool[k] = v +fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(fd, "w") as f: + json.dump(pool, f, indent=2) +PYEOF + if [ -n "$live" ]; then + [ -s "$live" ] && cred_install "$live" "" + rm -f "$live" + fi +} + +# Print credential $1 with its mcpOAuth replaced by the JSON object in file $2, +# or removed when $2 is empty. Every other key, claudeAiOauth included, is left +# exactly as it is. +mcp_with() { # $1=credential file $2=mcpOAuth json file, or "" + python3 - "$1" "$2" <<'PYEOF' +import json, sys +d = json.load(open(sys.argv[1])) +d.pop("mcpOAuth", None) +if sys.argv[2]: + d["mcpOAuth"] = json.load(open(sys.argv[2])) +print(json.dumps(d)) +PYEOF +} + +# Install credential $1 as live for account $2 ("" for one ccswitch does not +# track): a shared account gets the pool's MCP tokens, an isolated one its own. +cred_install() { # $1=credential file $2=account name, or "" + if [ -n "$2" ] && mcp_isolated "$2"; then + cred_write "$1" + return + fi + mcp_pool_init + local tmp; tmp="$(mktemp)" + if mcp_with "$1" "$MCP_POOL" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then + cred_write "$tmp" + else + cred_write "$1" + fi + rm -f "$tmp" +} + +# Take the MCP tokens of credential $1, captured from shared account $2, as the +# pool. The live credential is the truth, so a server signed out of drops out +# of the pool. A credential with no mcpOAuth key at all - a fresh /login writes +# one - says nothing about MCP sign-ins, and leaves the pool alone. +mcp_absorb() { # $1=credential file $2=account name + mcp_isolated "$2" && return 0 + mcp_pool_init + python3 - "$1" "$MCP_POOL" <<'PYEOF' +import json, os, sys +try: + m = json.load(open(sys.argv[1])).get("mcpOAuth") +except Exception: + raise SystemExit +if not isinstance(m, dict): + raise SystemExit +try: + if json.load(open(sys.argv[2])) == m: + raise SystemExit +except SystemExit: + raise +except Exception: + pass +tmp = sys.argv[2] + ".tmp" +fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(fd, "w") as f: + json.dump(m, f, indent=2) +os.replace(tmp, sys.argv[2]) +PYEOF +} + # Snapshot the live credential+identity into an account slot. capture_into() { # $1=account name local n="$1" d="$ACCOUNTS/$1" @@ -316,6 +433,7 @@ capture_into() { # $1=account name mkdir -p "$d" mv "$tmp" "$d/credentials.json" + mcp_absorb "$d/credentials.json" "$n" identity_extract "$CC_JSON" > "$d/identity.json" chmod 700 "$d"; chmod 600 "$d"/*.json note_host "$n" @@ -325,7 +443,7 @@ capture_into() { # $1=account name restore_from() { # $1=account name local n="$1" d="$ACCOUNTS/$1" [ -f "$d/credentials.json" ] || die "account '$n' has no stored credential" - cred_write "$d/credentials.json" + cred_install "$d/credentials.json" "$n" [ -f "$d/identity.json" ] && identity_apply "$CC_JSON" "$d/identity.json" printf '%s\n' "$n" > "$ACTIVE_FILE" } @@ -490,7 +608,7 @@ route_stray() { # $1=live credential file $2=owning account $3=live identity' warn "a claude session still signed in as ${B}$owner${RST} renewed its token - saved it to $owner" fi if [ -n "$cur" ] && [ "$cur" != "$owner" ] && acct_exists "$cur"; then - cred_write "$ACCOUNTS/$cur/credentials.json" + cred_install "$ACCOUNTS/$cur/credentials.json" "$cur" warn "put ${B}$cur${RST}'s credential back - exit the claude sessions still open as $owner" fi } @@ -553,6 +671,7 @@ sync_active() { mkdir -p "$ACCOUNTS/$cur" mv "$tmp" "$stored" chmod 600 "$stored" + mcp_absorb "$stored" "$cur" identity_extract "$CC_JSON" > "$ACCOUNTS/$cur/identity.json" chmod 600 "$ACCOUNTS/$cur/identity.json" # The live credential is this account's, so this account is what is signed @@ -666,7 +785,8 @@ cmd_login() { fi capture_into "$n" - printf '%s\n' "$n" > "$ACTIVE_FILE" + # A fresh sign-in comes without MCP tokens; hand it the shared ones. + restore_from "$n" info "${GRN}saved${RST} $n ($(acct_email "$n")) $(fmt_left "$(acct_expiry "$n")")" } @@ -789,7 +909,7 @@ cmd_refresh() { if [ -n "$started" ] && acct_exists "$started"; then restore_from "$started" elif [ -f "$stash/credentials.json" ]; then - cred_write "$stash/credentials.json" + cred_install "$stash/credentials.json" "" identity_apply "$CC_JSON" "$stash/identity.json" rm -f "$ACTIVE_FILE" else @@ -924,6 +1044,76 @@ EOF esac } +mcp_servers() { # $1=file holding an mcpOAuth object, or a credential -> names + python3 - "$1" <<'PYEOF' +import json, os, sys +p = sys.argv[1] +try: + d = json.load(open(p)) if os.path.exists(p) else {} +except Exception: + d = {} +m = d.get("mcpOAuth", d) if isinstance(d, dict) else {} +names = sorted({(v.get("serverName") if isinstance(v, dict) else None) or k.split("|")[0] + for k, v in (m if isinstance(m, dict) else {}).items()}) +print(", ".join(names) or "none") +PYEOF +} + +# Drop account $1's own MCP tokens, and when it is the live account put the +# tokens it should now use in place. +mcp_reset_slot() { # $1=account name + local n="$1" slot="$ACCOUNTS/$1/credentials.json" tmp + if [ -f "$slot" ]; then + tmp="$(mktemp)" + mcp_with "$slot" "" > "$tmp" + mv "$tmp" "$slot" + chmod 600 "$slot" + fi + if [ "$n" = "$(active_name)" ] && [ -f "$slot" ]; then + cred_install "$slot" "$n" + fi +} + +cmd_mcp() { + local sub="${1:-}" n="${2:-}" force="${3:-}" + case "$sub" in + ""|status|list) + mcp_pool_init + info "${B}shared${RST} $(mcp_servers "$MCP_POOL")" + local d + for d in "$ACCOUNTS"/*/; do + [ -d "$d" ] || continue + n="$(basename "$d")" + if mcp_isolated "$n"; then + printf ' %-12s isolated %s\n' "$n" "$(mcp_servers "$d/credentials.json")" + else + printf ' %-12s shared\n' "$n" + fi + done + ;; + isolate|share) + [ -n "$n" ] || die "usage: ccswitch mcp $sub " + acct_exists "$n" || die "no such account '$n' (see: ccswitch list)" + if [ "$n" = "$(active_name)" ] && [ "$force" != "--force" ] && claude_running; then + die "a claude process is running as $n - exit it first, or pass --force" + fi + if [ "$sub" = "isolate" ]; then + mcp_isolated "$n" && { info "$n already keeps its own MCP sign-ins"; return 0; } + # The live tokens are already in the pool: every run syncs them first. + touch "$ACCOUNTS/$n/mcp-isolated" + mcp_reset_slot "$n" + info "${GRN}isolated${RST} $n - it signs in to MCP servers on its own; the shared sign-ins are kept for the other accounts" + else + mcp_isolated "$n" || { info "$n already uses the shared MCP sign-ins"; return 0; } + rm -f "$ACCOUNTS/$n/mcp-isolated" + mcp_reset_slot "$n" + info "${GRN}shared${RST} $n - it uses the shared MCP sign-ins again; its own were discarded" + fi + ;; + *) die "usage: ccswitch mcp [status | isolate [--force] | share [--force]]" ;; + esac +} + cmd_backup() { local out="${1:-$HOME/ccswitch-backup-$(date +%F-%H%M).tar.gz}" tar czf "$out" -C "$(dirname "$VAULT")" "$(basename "$VAULT")" @@ -1157,7 +1347,7 @@ _ccswitch() { case "$prev" in use|rm|rename|login|refresh|save) COMPREPLY=($(compgen -W "$accounts" -- "$cur")); return;; esac - COMPREPLY=($(compgen -W "use add login list current rm rename save sync autosync refresh backup restore doctor update completion help version" -- "$cur")) + COMPREPLY=($(compgen -W "use add login list current rm rename mcp save sync autosync refresh backup restore doctor update completion help version" -- "$cur")) } complete -F _ccswitch ccswitch EOF @@ -1167,7 +1357,7 @@ EOF _ccswitch() { local -a accounts cmds accounts=(${(f)"$(ls -1 ${CCSWITCH_HOME:-${XDG_CONFIG_HOME:-$HOME/.config}/ccswitch}/accounts 2>/dev/null)"}) - cmds=(use add login list current rm rename save sync autosync refresh backup restore doctor update completion help version) + cmds=(use add login list current rm rename mcp save sync autosync refresh backup restore doctor update completion help version) if (( CURRENT == 2 )); then _describe 'command' cmds _describe 'account' accounts @@ -1203,6 +1393,9 @@ ${B}USAGE${RST} refresh a stored token before it goes stale ccswitch rm forget an account ccswitch rename rename an account + ccswitch mcp show which accounts share MCP server sign-ins + ccswitch mcp isolate give an account its own MCP sign-ins + ccswitch mcp share put an account back on the shared ones ccswitch backup [file] archive the vault ccswitch restore restore a vault archive ccswitch doctor diagnose the setup @@ -1236,7 +1429,7 @@ cmd="${1:-}"; shift 2>/dev/null || true # whole vault, and for 'help'/'version', which touch nothing. case "$cmd" in restore|help|-h|--help|version|-V|--version|completion|update|upgrade) ;; - *) sync_active ;; + *) mcp_pool_init; sync_active ;; esac case "$cmd" in use) cmd_use "${1:-}" "${2:-}" ;; @@ -1250,6 +1443,7 @@ case "$cmd" in refresh) cmd_refresh "${1:-}" "${2:-}" ;; rm|remove|delete) cmd_rm "${1:-}" ;; rename|mv) cmd_rename "${1:-}" "${2:-}" ;; + mcp) cmd_mcp "${1:-}" "${2:-}" "${3:-}" ;; backup) cmd_backup "${1:-}" ;; restore) cmd_restore "${1:-}" ;; doctor) cmd_doctor ;; diff --git a/test/keychain.sh b/test/keychain.sh index 0c804f2..d23354d 100755 --- a/test/keychain.sh +++ b/test/keychain.sh @@ -92,6 +92,18 @@ os.system('security add-generic-password -U -a \"\$USER\" -s \"Claude Code-crede ccswitch beta --force >/dev/null; ccswitch alpha --force >/dev/null check "keychain refresh persisted" "$(tok)" "TOK-A2" +# MCP server tokens in the keychain item are shared, not swapped with the login +python3 -c " +import json,os,subprocess +cur=json.loads(subprocess.check_output(['security','find-generic-password','-a',os.environ['USER'],'-s','Claude Code-credentials','-w'])) +cur['mcpOAuth']={'figma|k':{'serverName':'figma','accessToken':'FIG-KC'}} +os.system('security add-generic-password -U -a \"\$USER\" -s \"Claude Code-credentials\" -X '+json.dumps(cur).encode().hex())" +ccswitch beta --force >/dev/null +check "keychain: mcp token follows the switch" \ + "$(kc_get "Claude Code-credentials" | python3 -c 'import json,sys;print(json.load(sys.stdin)["mcpOAuth"]["figma|k"]["accessToken"])' 2>/dev/null || echo MISSING)" "FIG-KC" +check "keychain: claude token still swapped" "$(tok)" "TOK-B" +ccswitch alpha --force >/dev/null + # every account still holds a valid token, so this must skip without needing # 'claude' and must leave the live item alone ccswitch refresh --all >/dev/null diff --git a/test/smoke.sh b/test/smoke.sh index f5c5545..2515230 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -83,6 +83,94 @@ json.dump({'claudeAiOauth':{'accessToken':'TOK-A2','refreshToken':'r2','expiresA ccswitch beta --force >/dev/null; ccswitch alpha --force >/dev/null check "refresh persisted" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-A2" +# MCP server tokens share the credential blob but belong to your Figma, Sentry +# ... login, not the Claude account - a switch must carry them over as they are. +live_mcp() { # $1=server $2=token $3=hours, or "" for a token with no expiry + python3 - "$1" "$2" "$3" <<'PY' +import json, os, sys, time +p = os.environ["HOME"] + "/.claude/.credentials.json" +d = json.load(open(p)) +e = {"serverName": sys.argv[1], "accessToken": sys.argv[2]} +if sys.argv[3]: + e["expiresAt"] = int((time.time() + float(sys.argv[3]) * 3600) * 1000) +d.setdefault("mcpOAuth", {})[sys.argv[1] + "|k"] = e +json.dump(d, open(p, "w")) +PY +} +mcp_tok() { j /.claude/.credentials.json "['mcpOAuth']['$1|k']['accessToken']"; } + +live_mcp figma FIG-1 2 +ccswitch beta --force >/dev/null +check "mcp: token follows the switch" "$(mcp_tok figma)" "FIG-1" +check "mcp: claude token still swapped" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-B" + +# renewed while on beta; alpha's slot still holds the superseded FIG-1 +live_mcp figma FIG-2 5 +live_mcp posthog PH-B "" +ccswitch alpha --force >/dev/null +check "mcp: stale slot copy loses to the renewal" "$(mcp_tok figma)" "FIG-2" +check "mcp: token without expiry carried" "$(mcp_tok posthog)" "PH-B" +check "mcp: claude token is alpha's" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-A2" + +# Upgrading from a vault with no shared pool: a server signed in only under +# one account's stored copy is gathered up, and the newest copy of each wins. +rm -f "$CCSWITCH_HOME/mcp-oauth.json" +python3 - <<'PY' +import json, os, time +p = os.environ["CCSWITCH_HOME"] + "/accounts/beta/credentials.json" +d = json.load(open(p)) +d.setdefault("mcpOAuth", {})["sanity|k"] = {"serverName": "sanity", "accessToken": "SAN-B"} +d["mcpOAuth"]["figma|k"] = {"serverName": "figma", "accessToken": "FIG-OLD", + "expiresAt": int((time.time() + 3600) * 1000)} +json.dump(d, open(p, "w")) +PY +ccswitch list >/dev/null +check "mcp: upgrade gathers every account's servers" "$(mcp_tok sanity)" "SAN-B" +check "mcp: upgrade keeps the newest copy" "$(mcp_tok figma)" "FIG-2" +ccswitch beta --force >/dev/null +check "mcp: beta's claude token intact" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-B" +ccswitch alpha --force >/dev/null +check "mcp: round trip keeps every server" "$(mcp_tok sanity) $(mcp_tok figma) $(mcp_tok posthog)" "SAN-B FIG-2 PH-B" + +# signing out of a server sticks - no stored copy brings it back +python3 - <<'PY' +import json, os +p = os.environ["HOME"] + "/.claude/.credentials.json" +d = json.load(open(p)); del d["mcpOAuth"]["sanity|k"]; json.dump(d, open(p, "w")) +PY +ccswitch beta --force >/dev/null; ccswitch alpha --force >/dev/null +check "mcp: sign-out is not undone by a switch" "$(mcp_tok sanity)" "MISSING" + +# a fresh /login writes a credential with no MCP tokens at all; that says +# nothing about them, and must not empty the pool +python3 - <<'PY' +import json, os +p = os.environ["HOME"] + "/.claude/.credentials.json" +d = json.load(open(p)); del d["mcpOAuth"]; json.dump(d, open(p, "w")) +PY +ccswitch list >/dev/null; ccswitch beta --force >/dev/null +check "mcp: credential without MCP tokens leaves the pool alone" "$(mcp_tok figma)" "FIG-2" + +# an isolated account keeps its own MCP sign-ins, apart from the shared ones +ccswitch mcp isolate beta --force >/dev/null +check "mcp isolate: active account starts signed out" "$(mcp_tok figma)" "MISSING" +check "mcp isolate: claude token untouched" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-B" +live_mcp figma FIG-CLIENT 5 +ccswitch alpha --force >/dev/null +check "mcp isolate: shared accounts keep the shared token" "$(mcp_tok figma)" "FIG-2" +live_mcp figma FIG-3 6 +ccswitch beta --force >/dev/null +check "mcp isolate: isolated account gets its own back" "$(mcp_tok figma)" "FIG-CLIENT" +check "mcp status shows isolation" "$(ccswitch mcp | grep -c 'beta.*isolated')" "1" +ccswitch alpha --force >/dev/null +check "mcp isolate: its token never reaches the pool" "$(mcp_tok figma)" "FIG-3" + +ccswitch mcp share beta --force >/dev/null +ccswitch beta --force >/dev/null +check "mcp share: account rejoins the pool" "$(mcp_tok figma)" "FIG-3" +check "mcp share: claude token untouched" "$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-B" +ccswitch alpha --force >/dev/null + # Fix for the daily-relogin bug: Claude Code rotates the refresh token during a # session and on /login, neither of which goes through ccswitch. Any ccswitch # run must pull that into the active slot, or the vault keeps a consumed token.