Files
mcpctl/docs/claude-integration.md
Michal 2a7bba11ea fix(claude): stop pre-migration .mcp.json residue outranking a project switch
`mcpctl config claude --project X` writes user scope and never rewrites a
checkout's `.mcp.json`. The status line, however, preferred that file
unconditionally — so a legacy project-named entry an older mcpctl left behind
(`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project
for good, and every switch looked like it had done nothing.

Reproduced live: with user scope on `sre`, `mcpctl statusline --directory
~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project
Claude Code also had in `disabledMcpServers` for that directory, so the line
named a server that was not even mounted.

Rank the sources by how deliberate each one is instead: a canonical `mcpctl`
pin, then user scope, then legacy residue, then the marker. A pin is a decision
and still wins; residue is not and no longer does. At every step, skip a server
Claude Code has switched off for that directory.

`config claude` now also warns when the working directory's `.mcp.json`
contradicts the switch, naming the file — the two scopes are merged rather than
chosen between, so nothing else would tell you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 12:15:46 +01:00

8.1 KiB
Raw Permalink Blame History

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.

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.jsonbeside ~/.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:

{ "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 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 then takes the project from the most deliberate source that names one:

  1. a canonical mcpctl entry in that directory's .mcp.json — a repo that pinned itself wins, and it is the scope Claude Code itself prefers when both define that server name;
  2. the user-scope entry in .claude.json — what config claude --project writes, so a switch takes effect everywhere it is not overridden;
  3. a legacy project-named entry in .mcp.json (homeautomation, docmost, …), left by an mcpctl older than the constant server name;
  4. a .mcpctl-project marker up the tree, so a checkout that is scoped but not yet wired still reports.

Legacy entries rank below user scope on purpose. They used to outrank it, which made switching look broken: a user-scope switch never rewrites a checkout's .mcp.json, so the leftover kept naming the old project for good. A pin is a decision; residue is not.

A server Claude Code has switched off for that directory (disabledMcpServers / disabledMcpjsonServers) is skipped at every step — a disabled server is not mounted, so naming its project would be a lie. A .mcp.json server that is in neither list is still awaiting its approval prompt and does count, since blanking the status line on a fresh checkout is the more confusing failure.

When a directory contradicts a switch

Claude Code merges the two scopes rather than picking one, so switching in user scope cannot clean up what a directory declares. config claude says so rather than reporting plain success:

Warning: /path/to/repo/.mcp.json still registers 'homeautomation' for this
directory — mounted alongside 'sre', not replaced by it.
  Re-run with --scope project to retire it, or delete the entry by hand.

A canonical entry pinned to another project gets the stronger wording — it overrides the switch in that directory rather than sitting beside it.

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.