Files
mcpctl/docs/claude-integration.md
Michal b7c0de2bf0 feat(claude): register the MCP server in user scope by default
`config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in
every checkout you opened — and in a repo that commits `.mcp.json` (this one
does) it dirtied the working tree. Every other integration is already global:
pi, prime-agent and opencode each have one active project, wired once.

Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in
every directory and window. That is now the default. `--scope project`, or an
explicit `-o/--output`, keeps the old per-directory file for a repo that wants
its own pinned project. `--inspect` stays project-scope — it is a debugging
server you turn on for one checkout.

Details worth knowing:

  - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is
    set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside
    it. Verified against a live Claude Code run with an isolated config dir.
  - `.claude.json` also holds onboarding state, caches and a per-project map
    that Claude Code rewrites while running, so this merges into the document
    and writes through a temp file + rename.
  - 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.
  - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that
    pinned itself wins), then user scope, then the marker.

Scope selection reads Commander's option source rather than process.argv —
argv is the test runner's command line when the command is driven in-process,
which the suite caught immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00

147 lines
6.5 KiB
Markdown
Raw Permalink 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` 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).