ZeroStarterRC

Race-Free Identity in Claude Code: Per-Workspace MCP, GitHub, and Git

Make MCP servers, GitHub auth, and git commit identity follow the directory you're in, so concurrent Claude Code sessions never bleed work, client, and personal accounts into each other. No manual switching, no shared global state to race.

Run two Claude Code sessions at once, one in a work project and one in a personal one, and let both reach for the GitHub API. The work session points gh at your work account. A moment later the personal session points it at yours, so the work session's next call goes out as the wrong user. It switches back, which breaks the personal session, which switches back again. They never settle, because both fall back to gh's single active account for github.com, and that account is shared global state.

Two Claude Code sessions both switching gh's single active account, so the work session's next call goes out as the wrong user

You can't fix this with discipline; there's no "remember to switch back" when two sessions are switching at the same time. And it isn't only gh: the same shared-state bug stamps commits with the wrong email and leaves your assistant's Linear pointed at last week's workspace. The fix is to stop sharing a mutable "current account" at all, and let identity follow where you are.

The Idea

Your directory already knows which org you're working for, so scope everything to it. Each shell reads its own location and resolves its own identity, with nothing global to toggle. Two terminals in two trees run at once because they share no mutable state to fight over.

Each directory resolves to its own identity: ~/acme to the acme GitHub account, its MCP servers, and the work git email; ~/nrjdalal to the nrjdalal account, its own servers, and the personal email; with nothing shared between them

That's three small pieces: the MCP servers a workspace can see, the tokens it authenticates with, and the email on its commits. The examples use a work account (acme) and a personal one (nrjdalal), on macOS and zsh with Claude Code.


1. MCP servers, per workspace

Claude Code reads MCP servers from a .mcp.json, and it searches upward from your current directory to find one, crossing git-repo boundaries on the way. Put one at a workspace root and every repo nested under it inherits those servers:

~/acme/
├── .mcp.json        ← inherited by every repo below
├── api/
└── dashboard/
~/nrjdalal/
└── .mcp.json        ← a different set entirely

A work ~/acme/.mcp.json holds its GitHub server, Linear, Notion, and Slack:

{
  "mcpServers": {
    "github-acme": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/",
      "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
    },
    "linear-acme": { "type": "http", "url": "https://mcp.linear.app/mcp" },
    "notion-acme": { "type": "http", "url": "https://mcp.notion.com/mcp" },
    "slack-acme": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "slack-mcp-server@1.3.0", "--transport", "stdio"],
      "env": {
        "SLACK_MCP_XOXP_TOKEN": "${SLACK_MCP_XOXP_TOKEN}",
        "SLACK_MCP_ADD_MESSAGE_TOOL": "true"
      }
    }
  }
}

~/nrjdalal/.mcp.json mirrors it with *-nrjdalal names. (SLACK_MCP_ADD_MESSAGE_TOOL lets the agent post to Slack, not just read; drop it for read-only.) Three things matter here:

  • Give OAuth servers unique names (linear-acme, not linear). Claude Code stores their tokens keyed by server name, so two servers both called linear would share one token and you couldn't be signed into both Linear workspaces. This isolates credentials; it isn't an access-control boundary.
  • The GitHub and Slack servers take their tokens from environment variables (${GITHUB_TOKEN}, ${SLACK_MCP_XOXP_TOKEN}), not a stored grant. That's the hook into the next piece.
  • Pin the stdio server's version. npx ...@latest runs whatever shipped most recently, sight unseen, and you're handing it a token; pin a version you've reviewed.

Because these files live at workspace roots, not inside any repo, they never get committed to a teammate's checkout.


2. One hook resolves the tokens, by directory

Both ${GITHUB_TOKEN} and ${SLACK_MCP_XOXP_TOKEN} are read from the environment when a server connects. So the whole trick is to make them resolve to the right account based on where you are. A single zsh chpwd hook does it, pulling tokens live from the gh keyring and the macOS keychain so nothing sensitive lands on disk:

# ~/.config/claude/mcp-tokens.zsh: one hook, every per-workspace secret from $PWD.
#   ~/acme/      → gh: acme       slack: slack-acme-mcp-xoxp
#   ~/nrjdalal/  → gh: nrjdalal   slack: slack-nrjdalal-mcp-xoxp
__claude_resolve_identity() {
  local ws
  case "${PWD}/" in
    "$HOME/acme/"*)     ws=acme ;;
    "$HOME/nrjdalal/"*) ws=nrjdalal ;;
    *)                  ws="" ;;
  esac
  [[ "$ws" == "$__claude_ws" ]] && return            # workspace unchanged, skip the lookups
  __claude_ws="$ws"
  unset GITHUB_TOKEN GH_TOKEN SLACK_MCP_XOXP_TOKEN    # clear the previous (and any inherited) tokens

  local gh_user slack_svc
  case "$ws" in
    acme)     gh_user=acme;     slack_svc=slack-acme-mcp-xoxp ;;
    nrjdalal) gh_user=nrjdalal; slack_svc=slack-nrjdalal-mcp-xoxp ;;
    *)        return ;;                              # outside a mapped workspace: nothing set
  esac

  local tok
  if tok="$(gh auth token -u "$gh_user" 2>/dev/null)" && [[ -n "$tok" ]]; then
    export GITHUB_TOKEN="$tok" GH_TOKEN="$tok"
  else
    print -u2 "mcp-tokens: no gh token for $gh_user; GITHUB_TOKEN/GH_TOKEN unset"
  fi
  if tok="$(security find-generic-password -a "$USER" -s "$slack_svc" -w 2>/dev/null)" && [[ -n "$tok" ]]; then
    export SLACK_MCP_XOXP_TOKEN="$tok"
  else
    print -u2 "mcp-tokens: $slack_svc not in keychain; SLACK_MCP_XOXP_TOKEN unset"
  fi
}

autoload -Uz add-zsh-hook
add-zsh-hook chpwd __claude_resolve_identity
__claude_ws="__init__"          # force the first resolve, clearing any inherited token
__claude_resolve_identity

Source it from ~/.zshrc, log both accounts into gh once (gh auth login), and store each Slack token in the keychain once:

security add-generic-password -U -a "$USER" -s slack-acme-mcp-xoxp -w 'xoxp-...'

Flowchart: on cd, if the directory is under ~/acme resolve the acme tokens, else if under ~/nrjdalal resolve the nrjdalal tokens, otherwise leave all tokens unset

A few details earn the design its keep:

  • It clears the tokens first and exports only when the lookup succeeds, warning on stderr otherwise, so a server never gets an empty Bearer and a missing token fails loudly, not silently.
  • It exports both GITHUB_TOKEN and GH_TOKEN. gh prefers GH_TOKEN, so setting only one risks an inherited GH_TOKEN making gh disagree with the MCP header.
  • It caches the workspace and only shells out when you actually cross into a different one, not on every cd.
  • The __init__ sentinel forces a clean first resolve, so a fresh shell never trusts a token a parent leaked in.

There's a bonus. Because gh reads GH_TOKEN/GITHUB_TOKEN before its keyring, that same variable also steers the gh CLI, and git push/pull when gh is your credential helper (gh auth setup-git). One token, three tools, for HTTPS remotes only; SSH remotes authenticate with keys and ignore GH_TOKEN.

Why not a .env per workspace? Because then the filesystem becomes the secret store, with all the ways that rots: a stale token, a forgotten export, a .env committed by accident. Pulling from the gh keyring and the keychain keeps .mcp.json safe to read and the shell the only place identity lives.

That's the split worth keeping: the .mcp.json declares capabilities; the shell supplies identity.


3. The right email on every commit

Auth says who you act as; it says nothing about the name on a commit. Git handles that itself with conditional includes. In ~/.gitconfig:

[user]
    name = Neeraj Dalal
    email = admin@nrjdalal.com        # personal is the default
[includeIf "gitdir:~/acme/"]
    path = ~/.config/git/acme.gitconfig

and ~/.config/git/acme.gitconfig overrides the email under ~/acme/:

[user]
    email = neeraj@acme.com

The trailing slash makes gitdir:~/acme/ match every repo in the tree. It's read-only and evaluated per repo, so it stays correct no matter what another terminal is doing.


Why concurrent sessions don't clash

That's the whole point. Open one terminal in ~/acme/api and another in ~/nrjdalal/blog and run them together:

  • Tokens are per-process environment variables. Each shell holds its own from its own cwd; there's no shared "active account" to toggle.
  • Commit identity is per-path and read-only through includeIf.
  • MCP servers load from each session's own .mcp.json at launch, with distinct names (linear-acme vs linear-nrjdalal) so even stored OAuth tokens never collide.

Nothing mutates shared state, so there's nothing to race. The one global thing left, gh's keyring "active account", only matters outside your mapped trees. A repo that lives somewhere other than ~/acme or ~/nrjdalal gets no workspace tokens and commits under your default (personal) identity, which is the safe fallback.


Verify it

From a session in a workspace, ask the GitHub MCP server who it is (get_me), and check the shell in one command:

echo "pwd:   $PWD"
echo "gh:    $(gh api user --jq .login 2>/dev/null || echo none)"
echo "git:   $(git config user.email)"
echo "token: ${GITHUB_TOKEN:+set}"

In ~/acme that prints acme and neeraj@acme.com; in ~/nrjdalal, nrjdalal and admin@nrjdalal.com.


Gotchas

  • The environment is captured at launch. A server reads ${GITHUB_TOKEN} when it connects, and cd-ing elsewhere inside a running session won't re-resolve it. Launch claude from a terminal already in the workspace, and relaunch to switch.
  • git push follows the token only for HTTPS remotes. SSH remotes use your keys; solve that separately with ~/.ssh/config host aliases if you need it.
  • The upward .mcp.json search is observed behavior, not documented. It works today (checked on Claude Code 2.1.173, where an inherited server shows as ⏸ Pending approval); if a future version drops it, fall back to a .mcp.json per repo.

cd ~/acme/api and you're the work account, with work tools and your work email. cd ~/nrjdalal/blog and you're you. No toggles, no foot-guns, and it holds up with five terminals open at once, because not one of them shares anything to clash over.