# mcpctl × Claude Code ## What `mcpctl config claude --project X` wires up | Piece | Where | Purpose | |-------|-------|---------| | MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | | Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | | Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | | SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | | Status line | `~/.claude/settings.json` | the active project, bottom of the screen | | `/mcpctl` | `~/.claude/commands/mcpctl.md` | switch projects from inside a session | `--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything under `~/.claude`. ## User scope by default: wire it once, not per checkout Claude Code has two MCP scopes: **project** (`./.mcp.json`, that directory only, and usually committed — so writing to it dirties the repo) and **user** (`mcpServers` in `.claude.json`, every directory and every window). mcpctl now defaults to **user** scope. One active project everywhere is how the pi, prime-agent and opencode integrations already behave; per-directory wiring meant re-running `config claude` in every checkout you opened. Switching the project is then one command from anywhere. ```bash mcpctl config claude --project homeautomation # applies everywhere mcpctl config claude --project docmost --scope project # just this repo mcpctl config claude --project docmost -o .mcp.json # same thing; -o implies project scope ``` User scope writes **no `.mcpctl-project` marker** — it scopes nothing to a directory, and a marker beside `.claude.json` would sit in `$HOME` and scope every repo under it. `--inspect` stays project-scope: it is a debugging server you turn on for one checkout. > `.claude.json` also holds onboarding state, caches and a per-project map that > Claude Code rewrites while running, so mcpctl merges into it and writes > through a temp file + rename. A truncated write there costs far more than a > stale MCP entry. > > Note the path asymmetry: with `CLAUDE_CONFIG_DIR` set the file is > `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` — > *beside* `~/.claude/`, not inside it. ## One server named `mcpctl`, not one per project The MCP entry used to be named after the project. Because `.mcp.json` is *merged*, configuring a second project left the first mounted too — every project you had ever configured stayed connected, with duplicate tool names and nothing marking which was active. There is now exactly one entry, `mcpctl`, and switching rewrites what sits behind it: ```jsonc { "mcpServers": { "mcpctl": { "command": "mcpctl", "args": ["mcp", "-p", "docmost"] } } } ``` Two things follow. The tool prefix is stable across switches, so the model never sees a tool namespace disappear mid-conversation. And because Claude Code can reconnect an existing MCP server from `/mcp`, a switch lands without restarting the app. Entries an older CLI wrote are retired on the next run. They are recognised by the pairing that makes retiring them safe — *our* command, named after the very project it bridges to. A hand-configured server is never touched, even one called `docmost`, unless it also runs `mcpctl mcp -p docmost`, at which point it is the same entry anyway. ## Switching: `/mcpctl` ``` ❯ /mcpctl docmost ● Bash(mcpctl config claude --project docmost --skip-marker) ⎿ Wrote .mcp.json (1 server(s)) ● Now on docmost. Reconnect the mcpctl server from /mcp for its tools to load. ``` With no argument it lists the projects and asks. Claude Code slash commands are **prompt files, not code**, so unlike opencode's picker this drives the model through CLI calls — there is no keyboard picker to be had. `allowed-tools` is scoped to the four exact `mcpctl` invocations it needs, so accepting it does not hand the session a general shell. > Every `` !`…` `` block in a slash command is permission-checked against > `allowed-tools` *before* the model runs. Omitting one fails the whole command > with a permission error and no explanation. A test asserts every pre-executed > command in ours is covered. `--skip-marker` is deliberate: the session's directory is whatever you happened to open, and re-scoping it would silently change which skills sync into it. ## The status line ``` mcpctl:docmost ⏵⏵ bypass permissions on · ← for agents ``` `mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` first (a repo that pinned itself wins), then the user-scope entry in `.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet wired still reports. It reads the directory from the JSON Claude Code pipes in, so it follows `/cwd` rather than reporting wherever the binary was launched, and prints **nothing** when no project is active — an empty status line beats one saying "none" on every unrelated repo. ### It is never installed over yours A status line is a single slot, so overwriting a custom one silently deletes work. When `config claude` finds a foreign one it leaves it and prints the snippet to add instead: ``` Left your existing status line alone (my-fancy-prompt). To show the project too, append: $(mcpctl statusline) ``` > **Ownership is decided by the command string, not a marker.** Claude Code > rewrites `settings.json` against its own schema and **strips unknown keys from > `statusLine`** — a tagged entry comes back as a bare `{type, command}`. > (Hooks keep their marker; `statusLine` does not.) Matching on the command is > what stops us reporting our own line as foreign forever. A line that merely > *composes* ours — `my-prompt && mcpctl statusline` — is yours, and is left > alone. ## Skills `mcpctl skills sync` installs into `~/.claude/skills/`. Claude Code is the only target that also gets hooks, `postInstall` and `mcpServers` auto-attach; pi, prime-agent and opencode share the simpler flat-tree semantics. The SessionStart hook keeps them current. It carries a `_mcpctl_managed` marker, and an install now also drops **untagged duplicates of that exact command** — rows left behind before the marker existed (or by a test suite that used to write into a real `~/.claude`). They are invisible in the UI and just run the sync twice per session. A hook you wrote is never touched, even one that also calls `mcpctl skills sync` with different flags. ## Running Claude Code on the homelab LLM See [claude-vllm.md](claude-vllm.md).