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

182 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 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](claude-vllm.md).