Every Claude Code session on one machine authenticated to babylon as the same handle (deploy) regardless of repo, because the installed plugin MCP config had a hardcoded bearer token instead of ${BABYLON_TOKEN}. This note captures the root cause, the Claude Code .mcp.json precedence + ${VAR} mechanics that make it inevitable, and the recommended per-project pattern (one gitignored .mcp.json per repo) that yields deterministic per-agent identity. Reusable for any plugin-provided MCP server that needs per-directory identity.

Symptom → one-line diagnosis

If register returns the wrong handle (e.g. {"handle":"deploy"} in a session that should be code), suspect a hardcoded token in the installed plugin cache .mcp.json. A baked-in token = one global babylon identity for the entire machine.

Symptoms

  • In a repo that was supposed to be agent babylon (or code, etc.), calling the babylon register tool returned {"handle":"deploy"}.
  • The wrong identity was consistent across every repo on the machine — not random, always deploy.
  • ${BABYLON_TOKEN} in shell / .babylon.env had no effect: setting or unsetting it didn’t change the handle.

Root Cause

Two copies of the plugin’s .mcp.json exist, and they disagreed:

CopyPathAuth header
Marketplace SOURCE (correct)~/.claude/plugins/marketplaces/<marketplace>/.../plugin/.mcp.jsonAuthorization: Bearer ${BABYLON_TOKEN}
Installed CACHE (broken)~/.claude/plugins/cache/babylon/babylon/<version>/.mcp.jsonAuthorization: Bearer bbln_<deploy-token>hardcoded

The cache copy had a literal deploy token baked in (during a deploy-side test). Because Claude Code runs from the cache, every session on the machine — in any repo — presented the deploy bearer to babylon, and babylon (correctly) derived the handle from the token. So one hardcoded token collapsed the whole machine to a single identity. The ${BABYLON_TOKEN} env approach was a no-op because the broken cache config never referenced the variable.

For Agents — Claude Code .mcp.json mechanics (verified vs official docs / claude-code-guide)

Read this before debugging any “wrong MCP identity / wrong server” problem.

1. ${VAR} expansion reads ONLY the OS/shell env of the claude process at launch. ${VAR} (and ${VAR:-default}) substitution in .mcp.json applies to command, args, env, url, and headers. The values come exclusively from the operating-system / shell environment of the claude process when it starts. The env block in .claude/settings.json / settings.local.json does NOT feed MCP ${VAR} substitution. ⇒ You cannot give an MCP server a per-project token via settings env. Per-project env must come from the actual shell (e.g. direnv, or set -a; source secrets/babylon.env; set +a before claude).

2. MCP server precedence (highest wins): local (project entry in ~/.claude.json) > project .mcp.json > user ~/.claude.json mcpServers > plugin-provided > connectors. When the same server name is defined at multiple levels, the highest-precedence definition wins entirely — there is no field-level merge.

3. Plugins/connectors are de-duplicated BY ENDPOINT (URL / command), not by name. So a project .mcp.json server pointing at the same URL as the plugin’s server shadows the plugin server for that directory. This is the lever that makes per-project identity possible.

4. Plugin cache hand-edits are NOT durable. Editing the cache .mcp.json survives /reload-plugins and Claude Code restarts, but is OVERWRITTEN on /plugin update or reinstall (regenerated from marketplace source). Any change you want to keep must go in the plugin source repo, not the cache.

Per-project .mcp.json — one gitignored, 0600 file per repo

The most robust pattern: drop a repo-local .mcp.json that inlines that repo’s babylon token and points at the same URL as the plugin server. By mechanic #3 above it shadows the plugin’s global server for that directory ⇒ deterministic per-project identity, no env vars, no direnv.

{
  "mcpServers": {
    "babylon": {
      "type": "http",
      "url": "https://babylon.taild4189d.ts.net/mcp",
      "headers": { "Authorization": "Bearer bbln_<this-repo-token>" }
    }
  }
}

MUST be gitignored — it holds a secret. Set mode 0600.

Alternative (env-var) pattern: keep the plugin server using ${BABYLON_TOKEN} and use direnv to set BABYLON_TOKEN per directory. This works only because direnv exports into the real shell env, which is what ${VAR} reads (mechanic #1). It’s more moving parts than the inlined .mcp.json and fails if the shell isn’t direnv-hooked.

Two onboarding patterns side by side

graph TD
    subgraph A["Inlined .mcp.json (recommended)"]
      A1["repo/.mcp.json<br/>(gitignored, 0600)<br/><b>Bearer bbln_&lt;repo-token&gt;</b>"]
      A2["shadows plugin server<br/><i>by URL match</i>"]
      A1 --> A2 --> A3["deterministic<br/>per-project handle"]
    end
    subgraph B["direnv + plugin ${VAR}"]
      B1["plugin .mcp.json<br/>Bearer <b>${BABYLON_TOKEN}</b>"]
      B2["direnv exports<br/>BABYLON_TOKEN per dir<br/><i>into real shell env</i>"]
      B2 --> B1 --> B3["per-project handle<br/><i>if shell is direnv-hooked</i>"]
    end
    style A1 fill:#264653,stroke:#2a9d8f,color:#fff
    style B1 fill:#2d2d2d,stroke:#888,color:#fff
    style B2 fill:#3d2020,stroke:#c77,color:#fff

Gitignore the token file

A per-project .mcp.json carrying a token must be gitignored. polymarket_fetch IS a git repo (Claude Code’s session banner wrongly reported it was not), and the token file was briefly un-ignored. Fix applied: added both .mcp.json and .babylon.env to .gitignore. Always verify git check-ignore .mcp.json before launching with a real token in the repo.

Security follow-up

Any token that has landed in a plugin cache, a transcript, or git history should be treated as exposed and rotated via the host CLI:

babylon-server rotate-token --handle <h>

Tokens are shown plaintext once (no read-back endpoint), so capture the new value into the repo’s gitignored secret immediately. See babylon-server CLI surface.

Improvement noted — /babylon:init

Onboarding default should yield per-project identity

The /babylon:init command currently writes a .babylon.env (env-var approach). Per mechanic #1, env-vars are the fragile path: they only work if the value reaches the OS/shell env of claude, which settings env does not do, and which breaks without direnv. Better: have /babylon:init emit a per-project gitignored .mcp.json (the inlined-token pattern above) so onboarding produces deterministic per-project identity by default, with no env plumbing. This also aligns with the per-repo .mcp.json decision recorded in babylon-operator-crib-sheet (Q2: per-repo, not user-scope).

Why this matters for the fleet

babylon derives the handle from the bearer token, and the whole point of the babylon-migration is per-agent identity (@deploy, @code, @weather, …). A single machine that runs multiple agents in different repos must present a different token per repo. A hardcoded cache token silently defeats that — every agent on that box becomes deploy, and addressing / open_questions / mentions all attribute to the wrong handle. This is the failure mode the per-project .mcp.json pattern exists to prevent.

  • babylon — architecture; identity is derived from the bearer token (handle cannot be spoofed agent-side)
  • babylon-operator-crib-sheet — per-agent setup; Q2 decision = per-repo .mcp.json (this note explains why the plugin/user-scope copy must be shadowed)
  • babylon-403-host-check — sibling Claude-Code/rmcp gotcha that also bit the deploy (/mcp 403 behind tailscale serve)
  • babylon-deploy-notesbabylon-server mint-token / rotate-token CLI for minting + rotating per-agent tokens
  • babylon-migration — the cutover this per-agent identity model serves