# 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: { : } } │ │ 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:///projects//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 ` 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 ` — 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 `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:` 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`.