Merge remote-tracking branch 'origin/main' into feat/web-search-templates

# Conflicts:
#	completions/mcpctl.bash
#	completions/mcpctl.fish
#	src/cli/src/commands/create.ts
#	src/db/src/seed/index.ts
This commit is contained in:
Michal
2026-08-12 23:11:54 +01:00
83 changed files with 7354 additions and 318 deletions

181
docs/claude-integration.md Normal file
View File

@@ -0,0 +1,181 @@
# 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).

131
docs/claude-vllm.md Normal file
View File

@@ -0,0 +1,131 @@
# `claude-vllm` — Claude Code on the homelab LLM
## The short version
```bash
claude-vllm # default provider + model
claude-vllm --model deepseek-v4-max
claude-vllm --list # what would it use?
claude-vllm --print-env # the exports, without running claude
claude-vllm -- -p "summarise this repo" # anything after -- goes to claude
```
## Why no bridge is needed
The homelab gateway at `llm.ad.itaz.eu` is **LiteLLM in front of vLLM**, and
LiteLLM already serves the Anthropic Messages API:
```console
$ curl -s https://llm.ad.itaz.eu/v1/messages -X POST \
-H 'x-api-key: <key>' -H 'anthropic-version: 2023-06-01' \
-d '{"model":"deepseek-v4-fast","max_tokens":32,
"messages":[{"role":"user","content":"say only: ok"}]}'
{"type":"message","role":"assistant","model":"deepseek-v4-fast",
"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn", …}
```
That is the exact protocol Claude Code speaks. So pointing `ANTHROPIC_BASE_URL`
at the gateway is the whole integration — no translation layer, no proxy, no
mcpctl in the request path.
### Why not route it through mcpctl
mcpctl's LLM layer is a **client**, not a server: `mcpd` exposes
`/api/v1/llms` (management) and the adapters in
`src/mcpd/src/services/llm/adapters/` *call out* to Anthropic/OpenAI for gating,
prompt selection and agent chat. Nothing in mcpd or mcplocal serves
`/v1/messages`. A `claude-mcpctl` would therefore mean adding an
Anthropic-shaped passthrough to mcpd that re-wraps what LiteLLM already does —
new surface, new failure mode, no new capability.
It would only be worth it if you wanted mcpctl *in* the LLM path for its own
sake: per-project gating of model calls, audit of prompts, or budget
enforcement. Those are real features, but they are a passthrough endpoint in
mcpd, not a wrapper script.
## Its own config
```bash
claude-vllm --init # prompts for the key, writes 0600
claude-vllm --init --api-key '${MY_KEY}' # keep the secret in the environment instead
claude-vllm --init --base-url https://other-gateway/v1
```
Written to `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc` (`~/.config/…`), shaped
like `opencode.jsonc` — a `provider` map with `options.baseURL` /
`options.apiKey` and a `models` map:
```jsonc
{
"model": "itaz/deepseek-v4-think",
"provider": {
"itaz": {
"options": { "baseURL": "https://llm.ad.itaz.eu/v1", "apiKey": "${MY_LLM_KEY}" },
"models": { "deepseek-v4-fast": { "limit": { "context": 393216 } } }
}
}
}
```
**Same shape as opencode's config, but a separate file.** Reading opencode's own
config would mean a credential rotation there silently changing what Claude Code
authenticates with, and would couple two tools' configs for no reason.
### Keys are never stored in the tool
`apiKey` may be a literal (in a 0600 file), `${ENV_VAR}`, or a bare env var
NAME — so the secret can live in your environment or a password manager instead
of on disk. `--init` reads the key from **stdin** when `--api-key` is omitted,
keeping it out of shell history and out of the process table, where `--api-key`
would be visible to every user via `ps`. `--list` never prints more than a
10-character prefix.
### Discovery order
| Order | Source | Supplies |
|-------|--------|----------|
| 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected |
| 2 | `--provider` / `--model` flags | provider, model |
| 3 | `~/.config/mcpctl/claude-vllm.jsonc` | its own config |
| 4 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context windows · `auth.json` → key |
| 5 | `~/.prime/agent` | same shape |
The pi/prime homes remain a convenience fallback so `claude-vllm` works before
you have written a config at all. The base URL and the credential always come
from the *same* source, so one gateway's URL is never paired with another's key.
`--init` records the context window for **every** model it can see, not just the
active one — otherwise `--model something-else` silently falls back to Claude
Code's assumed 200k.
## What it sets, and why each one
| Variable | Reason |
|----------|--------|
| `ANTHROPIC_BASE_URL` | The gateway. A stored OpenAI-style `…/v1` base has the suffix stripped — Claude Code appends `/v1/messages` itself, and `/v1/v1/messages` 404s. |
| `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_API_KEY` | The gateway takes either; which one Claude Code sends has changed between releases. |
| `ANTHROPIC_MODEL` | The model to drive the session. |
| `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Background/summarisation calls otherwise ask for a real Haiku the gateway does not serve, and every one 404s. |
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code assumes 200k for models it has no table for. `deepseek-v4-*` is 393k, so without this it auto-compacts at half capacity. Taken from `contextWindow` in the provider config. |
| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Beta headers the gateway does not implement make it reject otherwise-fine requests. |
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | No telemetry about a non-Anthropic endpoint. |
## Expected noise
```
⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth
source is set and takes precedence over your claude.ai login
```
Unavoidable and harmless: it fires for *any* non-claude.ai auth, including
`ANTHROPIC_AUTH_TOKEN` alone (verified). It means your claude.ai org connectors
are not loaded for this session — which is the point of running against the
homelab.
## Verified
```console
$ claude-vllm --model deepseek-v4-fast -- -p "Reply with exactly: PARITY-OK"
claude-vllm: itaz · deepseek-v4-fast · https://llm.ad.itaz.eu
PARITY-OK
```

210
docs/opencode-extension.md Normal file
View File

@@ -0,0 +1,210 @@
# mcpctl × opencode — project switching from the TUI
## Motivation
`mcpctl config claude` and `mcpctl config prime-agent` both work the same way:
write the project's MCP server into the host's config file, then restart the
host so it picks the change up. That is fine when you pick a project once, and
tiresome when you switch between `homeautomation`, `sre` and `docmost` all day.
[opencode](https://opencode.ai) can do better, because it exposes two things
the other hosts do not:
- an **HTTP API for its own MCP registry** (`POST /mcp`,
`/mcp/{name}/disconnect`) — so a mount can be re-pointed while the app runs;
- a **TUI plugin API** (`tui.json`) with slash commands, dialogs and UI slots —
so the switcher can be a first-class part of the interface rather than a
shell command you run in another terminal.
So the opencode integration does not write an MCP entry into `opencode.json` at
all. It ships two plugins, and switching projects takes effect on the next turn
with no restart.
## How it works
```
~/.mcpctl/opencode-state.json (0600)
{ project, gatewayUrl, tokens: { <project>: <mcpctl PAT> } }
│ read by both plugins
~/.config/opencode/plugin/mcpctl.ts server plugin ─┐
~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤
│ client.mcp.add({
│ name: "mcpctl",
▼ type: "remote", url, headers })
opencode's MCP registry
https://<gateway>/projects/<project>/mcp
```
### The state file, not `opencode.json`
1. **The restart.** A config file is read at startup. Re-pointing the mount
through the running server's MCP API is what makes `/mcpctl` instant. This is
the load-bearing reason.
2. **The token.** Having chosen the gateway, we need
`Authorization: Bearer <mcpctl PAT>` somewhere. `opencode.json` is a mode-0644
file people paste into bug reports; `~/.mcpctl/opencode-state.json` is 0600,
like every other mcpctl credential.
> **Reason 2 is not an argument for this design over the alternative.** A
> `type: "local"` entry running `mcpctl mcp -p <project>` — the same stdio
> bridge `config claude` uses — needs no bearer token at all, because the bridge
> reads your own `~/.mcpctl/credentials`. So "no secret in a 0644 file" is not a
> point against that approach; it is just a consequence of having picked the
> HTTP gateway.
>
> The honest trade is: the gateway works against a remote mcpctl with no local
> `mcplocal` daemon, and mounts through an API that can be re-pointed live. The
> stdio bridge is simpler and credential-free, but requires `mcpctl` and a
> reachable mcplocal on the same machine. Both are defensible; this one was
> chosen for the remote case and for the live re-point, not for the token.
Tokens are kept **per project**, so switching back to a project you have
already used needs no new mint — and a failed mint for project B cannot cost
you the credential for project A.
### The server plugin (`plugin/mcpctl.ts`)
Auto-discovered by opencode. It mounts the active project's gateway under the
fixed MCP server name `mcpctl`, on the first server event and again before
every user turn.
Two details matter:
- **The name is constant.** Tools keep a stable `mcpctl_*` prefix across
switches, and because opencode re-resolves the tool list per request, the
model simply sees the new project's tools on its next turn. (Contrast pi,
where the extension has to *tell* the model its old tool names are dead.)
- **It does not re-register an unchanged mount.** `mcp.add` rebuilds the
connection, and mcplocal binds a gated project's unlocked state to that
connection's `mcp-session-id` — re-adding every turn would re-lock a project
you had just opened with `begin_session`.
It also exists so that **headless** runs (`opencode run …`), which load no TUI
plugins at all, still get the project's tools.
> The mount is deliberately *not* performed during plugin setup. Setup runs
> before opencode's server accepts connections, and `client.mcp.add` calls back
> into that same server — awaiting it there hangs the app on a blank screen
> before the TUI ever draws.
### The TUI plugin (`mcpctl/mcpctl-tui.tsx`)
Registered in `~/.config/opencode/tui.json` (opencode does not auto-discover
TUI plugins). It adds:
| Command | What it does |
|---------|--------------|
| `/mcpctl` (or `<leader>m`) | Filterable project picker; switches live |
| `/mcpctl-status` | Active project, mount state, gateway URL |
| `/mcpctl-skills` | Re-sync this project's skills |
Switching is the thing you do repeatedly, so it gets a chord as well as a slash
command; the other two stay palette-only.
and a `mcpctl:<project>` indicator in the prompt footer, next to the model name
and one line above the token counter.
Switching delegates to the CLI —
`mcpctl config opencode --project X --skip-plugin --skip-marker` — so token
minting, state and skills stay in one place and the plugin stays a UI shell.
`--skip-plugin` avoids rewriting the very file opencode has already loaded;
`--skip-marker` stops a switch from silently re-scoping whichever repository
opencode happened to be started in.
The picker needs no pre-filter prompt (unlike the pi and prime-agent
switchers): opencode's select dialog filters as you type, so the plugin only
has to order the list — active project first, then alphabetical.
A switch **disconnects before re-adding**. `mcp.add` under the same name does
re-point the tools on its own, but leaves it to opencode whether the previous
client is closed, and an abandoned one keeps its `mcp-session-id` alive on
mcplocal — the very thing that holds a gated project open. Best-effort: on a
first mount there is nothing to disconnect.
The indicator is published through `api.kv`, which is a reactive store: writing
it re-renders the slot with no signal plumbing, and it persists across sessions
so the label is right on the first frame.
#### Why the footer and not the status bar
opencode exposes UI slots, not arbitrary layout. In the footer region the
options are:
| Slot | Result |
|------|--------|
| `session_prompt_right` / `home_prompt_right` | **used** — renders on the prompt's bottom line, right of the model name, directly above the token counter |
| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it |
| `app_bottom` | costs a whole extra terminal row |
There is no slot on the status-bar line itself. The home prompt box is narrow
enough that the default wrap breaks `mcpctl:homeautomation` across two lines
mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of
a long name reads better than a two-line footer. In a session the prompt is full
width and it always fits.
### Skills
opencode implements the Agent Skills standard and loads `SKILL.md` trees from
`~/.config/opencode/skill/` (XDG-aware). `mcpctl skills sync --agent opencode`
syncs there, with the same shared-tree semantics as `--agent pi` and
`--agent prime-agent`: a flat tree with per-project ownership, its own state
file (`~/.mcpctl/skills-state-opencode.json`), no SessionStart hooks, no
`postInstall`, and untracked skill directories are never clobbered.
## Usage
```bash
mcpctl config opencode --project monitoring
```
That mints (or reuses) the project token, writes the 0600 state file, installs
both plugins, registers the TUI plugin in `tui.json`, and runs an initial
skills sync. Start opencode and the project's tools are there.
Skip individual steps:
```bash
mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint
mcpctl config opencode --project monitoring --skip-skills # don't sync skills
mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone
mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here
mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing
mcpctl config opencode --project monitoring --opencode-dir /path # non-default opencode config dir
```
## Failure semantics
- **No usable credential → the switch fails.** The command exits non-zero and
leaves the state file untouched, so the previously active project keeps
working rather than being replaced by a mount that 401s. `/mcpctl` reads that
exit code and reports the switch as failed instead of claiming success over a
project with no tools.
- **Gateway unreachable → no mcpctl tools.** Never a failed startup: the server
plugin swallows mount errors.
- **Corrupt `tui.json` → refuses to write.** One syntax error must not silently
drop every other TUI plugin you installed.
## Files
| Path | Purpose |
|------|---------|
| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin source |
| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin source |
| `src/cli/src/config/opencode-extension.ts` | Generated embed of both (do not edit) |
| `scripts/generate-opencode-extension.ts` | Regenerates that embed |
| `src/cli/src/utils/opencode-settings.ts` | Install / register / state helpers |
The plugin sources are **embedded** in the CLI so `mcpctl config opencode`
works from an installed binary with no source tree. After editing either
source, re-run:
```bash
npx tsx scripts/generate-opencode-extension.ts
```
A test fails if you forget. They are typechecked against the real
`@opencode-ai/plugin` types via `pnpm typecheck:opencode-ext`.

View File

@@ -101,9 +101,34 @@ src/pi-ext/
mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps)
```
The extension imports only from pi-bundled packages
(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it
loads standalone.
The extension imports only from pi-bundled packages, so it loads standalone.
### `typebox` is the only bare runtime import
pi does not let an extension resolve modules the ordinary way: it hands jiti a
hard-coded alias table built from its *own* dependencies, and that table is not
the same across pi distributions. The newer `@earendil-works/pi-coding-agent`
aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names;
older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only
the `@mariozechner/*` ones. Neither resolves the other's namespace.
So an import of anything outside the intersection kills the *whole* extension on
someone else's pi — every tool, the `/mcpctl` command, the status line — with:
```
Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai'
```
which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did.
`typebox` is aliased by every published pi, so it is the only bare specifier
allowed at runtime. Everything else must be a `node:` builtin, a relative path,
an `import type` (erased before jiti resolves anything), or inlined — pi-ai's
`StringEnum` is now a six-line local `stringEnum`. The
`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced
runtime import.
If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the
usual cause, and the extension has to load under whichever one wins.
## Typechecking

View File

@@ -0,0 +1,101 @@
# mcpctl × prime-agent
## What `mcpctl config prime-agent --project X` wires up
| Piece | Where | Purpose |
|-------|-------|---------|
| Proxy MCP | `~/.prime/agent/settings.json` | `{ type: "http", url: "<gateway>/projects/X/mcp" }` |
| Bearer credential | `~/.prime/agent/auth.json` | `mcp:X` → a minted mcpctl PAT |
| Skills | `~/.prime/agent/skills/` | the project's `SKILL.md` bundles |
| `/mcpctl` switcher | `~/.prime/agent/extensions/mcpctl-switch.ts` | switch projects, and the active-project indicator |
Unlike Claude Code (a stdio bridge, no token) and pi (native tools over
JSON-RPC), prime-agent talks to the **HTTP gateway**, so the switch is really
three things: a credential, a settings entry, and a reload.
## Credentials are the fragile part
`config prime-agent` mints an `mcptoken` per project, stores it under
`mcp:<project>`, and retires the one it replaced. Three rules make that safe:
- **A key being present proves nothing.** A revoked token would short-circuit
provisioning and leave prime-agent unable to reach the gateway while the
command reported success. mcptokens are shown once, so the stored token's
16-char `tokenPrefix` is compared against the project's *active* tokens rather
than sending the secret.
- **No credential means the switch fails.** Non-zero exit, `settings.json`
untouched, so the previously active project keeps working instead of being
replaced by a mount that 401s — and the `/mcpctl` switcher, which reads that
exit code, reports failure rather than success over a project with no tools.
- **Only the token we replaced is revoked.** Sweeping every `prime-agent` token
for a project would kill the one another machine is using. Anything else that
looks orphaned is reported, not deleted.
This plumbing is shared with `config opencode`, parameterised by agent rather
than copied.
## The `/mcpctl` switcher
`pi.registerCommand('mcpctl', …)` opens a picker, shells out to
`mcpctl config prime-agent --project X --skip-extension --skip-marker`, then
calls `ctx.reload()` — which re-reads `settings.json` and `auth.json` and
rebuilds the MCP map, so the switch lands without restarting the app.
`--skip-extension` stops it rewriting the very file it is running from;
`--skip-marker` stops it re-scoping whatever repository prime-agent was started
in, which Claude Code's own skills sync would then pick up.
Above 20 projects it asks for a filter first: prime-agent's selector is an
arrow-key list with no search, and real installs run to hundreds of projects.
(opencode's dialog filters as you type, so its switcher needs no such prompt.)
## The active-project indicator
Published with `ctx.ui.setStatus('mcpctl', …)`, which prime-agent renders in its
tray line next to the model name.
Two quirks worth knowing:
- prime-agent emits `session_start` **only from `reload()`**, never at startup —
so the indicator is also published on `turn_start`, the earliest moment with a
real UI context bound.
- `resetExtensionUI()` clears extension statuses *after* `session_start`, so the
indicator set there is wiped before anyone sees it. It is re-published on a
short retry schedule to land after that reset.
> The rendering itself only exists in prime-agent from
> `prime-agent-extension-status.patch` (upstream PR pending). On an unpatched
> build `setStatus` silently does nothing and no indicator appears.
## The extension is real source now
It used to exist **only** as a string literal inside
`src/cli/src/config/prime-agent-extension.ts` — so nothing typechecked or linted
it, which is precisely the 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`, checked against the real
`@earendil-works/pi-coding-agent` types:
```bash
pnpm typecheck:prime-agent-ext
npx tsx scripts/generate-prime-agent-extension.ts # after editing it
```
A test fails if the embed goes stale. Extracting it found six lint problems in
code that had never been linted — all null-guard and return-type issues rather
than live bugs, but exactly the class of thing that ships silently when nothing
is looking.
## What it deliberately does *not* do
The MCP entry is still **named after the project**, not the constant `mcpctl`
that `config claude` and `config opencode` now use. prime-agent's switcher
already unmounts the previous project, so it never accumulates entries the way
`config claude` did — the bug the constant name fixes does not exist here.
The remaining difference is tool-prefix stability: switching changes tool names,
so the model can hold stale ones. Moving prime-agent to a constant name would
also re-key `auth.json` from `mcp:<project>` to `mcp:mcpctl`, giving up
per-project token caching and needing a migration. Worth doing, but as its own
change rather than folded into a parity pass.

View File

@@ -884,6 +884,39 @@ All pushed to `mysources.co.uk/michal/` registry.
source .env && bash scripts/release.sh
```
**The build refuses to run from a branch that is behind `main`.** Everyone
branches off main, so a stale branch still builds and installs cleanly — it just
ships a binary missing whatever landed on main meanwhile, and `rpm -U --force`
overwrites the good one with it. That happened on 2026-08-10: a build from a
stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline`
command, months after the status line landed. `scripts/check-main-sync.sh`
fetches `main`, compares, and fails before any work happens, listing the commits
you are missing. It gates every path that produces something others consume:
`build-rpm.sh`, `build-deb.sh`, `build-mcpd.sh` (each is also run standalone, so
none can rely on another having checked) and `deploy-k8s.sh` — where a stale
branch would pin its sha in Pulumi and make it the cluster's source of truth.
`deploy-k8s.sh --dry-run` skips the check: it builds and cuts over nothing, and
blocking a read-only inspection only teaches people to export the escape hatch
permanently, disabling the gate for real deploys too.
```bash
git merge main # the fix
MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build
MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch
```
Offline it falls back to the last fetched `origin/main`, then to a local `main`,
and says which it used; outside a git checkout it skips entirely.
**A failing smoke run fails the release.** It used to print
`WARNING: Smoke tests failed!` and exit 0 — which is exactly how four broken
readiness probes shipped unnoticed (see `docs/reliability.md`): the warning
scrolled past and the release reported success. Note what the gate does and does
not do — smoke runs *last*, against the installed binary, so the package is
already published and installed by the time it fails. It reports the breakage
rather than preventing it, so investigate the fleet rather than assuming the
artifact is bad. Override with `MCPCTL_ALLOW_SMOKE_FAILURE=1`.
Installs via nfpm:
- `/usr/bin/mcpctl` — CLI binary (bun compiled)
- `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled)

View File

@@ -28,6 +28,78 @@ Note: the gate's prompt-ranking uses the **heavy client provider's own model**
it deliberately does *not* force the project's vLLM model onto it (doing so made
every selection fail silently when the model wasn't anthropic-servable).
## Instance health: `live` is not `healthy`
An MCP server answers `tools/list` from a **static, in-process table**. It costs
a few milliseconds, needs no credentials, and reaches no upstream — so it stays
green while the thing the server exists to talk to is unreachable. Treating that
as a health signal is how `mcpctl get instances` showed eight healthy servers
while the UniFi one had never once reached its controller.
So the probe reports two different passes:
| Status | Probe | Means |
|---|---|---|
| `healthy` | **readiness**`tools/call` on `healthCheck.tool` | The upstream answered. The server can do its job. |
| `live` | **liveness**`tools/list` only | The process is up and speaks MCP. Its upstream is **unverified**. |
| `degraded` | either, failing | Failing, but under `failureThreshold`. |
| `unhealthy` | either, failing | Failed `failureThreshold` times in a row. |
`live` is the default for any server with no `healthCheck.tool`. It is not a
warning — it is an admission that nothing is watching that server's upstream.
**Configure a readiness probe on every server.** Pick a read-only tool that
genuinely round-trips to the upstream, and verify it passes before configuring
it — a probe naming a local-only tool (`get_..._version`) or a tool the server
doesn't expose reproduces the same false green it was meant to remove.
```bash
mcpctl create server unifi-network --health-check-tool list_sites \
--health-check-interval 60 --health-check-timeout 15 --force
```
or declaratively — `healthCheck` round-trips through `get -o yaml | apply -f`:
```yaml
healthCheck:
tool: list_sites
arguments: {}
intervalSeconds: 60
timeoutSeconds: 15
failureThreshold: 3
```
Omit `tool` to keep liveness while still tuning the timings.
Latency is the tell: a probe answering in single-digit milliseconds is reading a
local table, not crossing a network. The UniFi probe went from 3ms (`tools/list`,
lying) to 1847ms on its first real `list_sites` — login, TLS, controller round
trip — and ~40ms once the session was warm.
### Where a failing readiness probe usually points
Turning these probes on for the first time took the fleet from "8/8 healthy" to
three genuine failures in under a minute. All three were network shape, not
code — check these before suspecting the server:
1. **Egress port.** MCP server pods default to TCP 80/443 only
(`servers-allow-external-egress`). Any upstream on another port — the UniFi
controller on `:8443` — times out on every call. Declare it in Pulumi's
`mcpctl.serverEgressTargets`; don't widen the blanket rule.
2. **Ingress hairpin.** A co-located service reached over its *public* hostname
goes out and back through the per-host Envoy L7 policy, which doesn't
reliably carry the caller's identity and replies with a bare `Access denied`.
Grafana 403'd on every call this way while the identical token succeeded from
a laptop. The tell is the error *shape*: plain text, not the upstream's own
JSON error. Use the ClusterIP (`serverEgressTargets` with `namespace:`).
3. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10`
address. Config pointing at one connect-timeouts forever. Use LAN IPs. (This
one turned out to be a retired service, which is its own kind of answer.)
Also check the *dialect*: UniFi's `controller_type` must be `classic` for a
self-hosted controller (login `/api/login`, no `/proxy/network` prefix).
`unifi_os` sends every request to a path that 404s.
## LLM-*essential* operations — failover chain
Chat needs *an* LLM but not a *specific* one. Instead of failing when the pinned

View File

@@ -7,7 +7,7 @@ all three are deployed by mcpctl like any other server.
|---|---|---|
| `duckduckgo` | `duckduckgo-mcp-server` (python) | nothing |
| `searxng` | `mcp-searxng` (node) | a SearXNG instance |
| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing (see the persistence caveat) |
| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing |
Search and docs are different jobs, not competing options. A search engine will
hand you a 2023 blog post with a stale method signature; a docs index cannot
@@ -109,9 +109,11 @@ The backing PVC is named after the *server* (`mcpctl-docs-data`), not the
instance, so editing the server or restarting the pod re-attaches to the same
index rather than starting empty. See "Persistent volumes" in the README.
The template carries no `healthCheck`, because `search_docs` needs a library
argument that only exists after a scrape — a synthetic probe would report
unhealthy on a fresh instance.
The readiness probe is `list_libraries`: no arguments, read-only, and it reads
the SQLite store, so a pass proves the volume is mounted and readable rather
than merely that the process started. It answers "No libraries indexed yet." on
a fresh instance instead of erroring, which is why `search_docs` (which needs a
library that only exists after a scrape) cannot serve as the probe.
### Embeddings