Files
mcpctl/docs/claude-integration.md
Michal a9fcd83ed8
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m37s
CI/CD / publish (pull_request) Has been skipped
refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs
The prime-agent switcher existed only as a 275-line string literal inside
prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap
that let a wrong ctx.ui.select() option shape ship in the pi extension. It now
lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig
checking it against the real @earendil-works/pi-coding-agent types, eslint
coverage and an embed-freshness test, matching pi and opencode.

The extraction was verified byte-identical before any edit, so the behaviour
shipped today is exactly what was captured. Linting it then found six problems
in code nothing had ever checked: object-truthiness null guards, a nullable
string conditional and a missing return type. All behaviour-preserving to fix,
but exactly the class of thing that ships silently when nothing is looking.

Also:

  - `config pi` gains --dry-run, the last agent without it.
  - The SessionStart hook installer now drops untagged duplicates of its own
    exact command — rows left behind before the marker existed, or by a suite
    that used to write into a real ~/.claude. Invisible in the UI; they just run
    the sync twice per session. A hook the user wrote is never touched, even one
    calling `mcpctl skills sync` with different flags.
  - docs/claude-integration.md and docs/prime-agent-extension.md, the two
    integrations that had no page.

prime-agent deliberately keeps its per-project MCP entry name rather than the
constant `mcpctl` claude and opencode now use: its switcher already unmounts the
previous project, so it never accumulates entries, and re-keying auth.json from
mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a
migration. Documented as its own change rather than folded in here.

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

4.9 KiB
Raw Blame History

mcpctl × Claude Code

What mcpctl config claude --project X wires up

Piece Where Purpose
MCP entry ./.mcp.json 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.

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 .mcp.json, falling back to 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.