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

6.5 KiB
Raw 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 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.