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
|
|
|
|
# mcpctl × Claude Code
|
|
|
|
|
|
|
|
|
|
|
|
## What `mcpctl config claude --project X` wires up
|
|
|
|
|
|
|
|
|
|
|
|
| Piece | Where | Purpose |
|
|
|
|
|
|
|-------|-------|---------|
|
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
|
|
|
|
| MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` |
|
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
|
|
|
|
| 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`.
|
|
|
|
|
|
|
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
|
|
|
|
## 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.
|
|
|
|
|
|
|
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
|
|
|
|
## 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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
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
|
|
|
|
`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.
|
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
|
|
|
|
|
|
|
|
|
|
### 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).
|