Three findings from a cross-branch review of the competing opencode
implementations, all of which are fair.
1. `readState` type-guards the parsed JSON now. A bare try/catch does not
cover it: `JSON.parse('null')` succeeds and returns null, so the catch never
fires and the next `state.project` throws a TypeError that takes the plugin
down. Verified the crash before fixing; a test pins the guard in the embedded
copies. Credit to the competing 'opencode-mine' branch, which had this right.
2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the
300-line TUI plugin — the largest file in the addon — was linted by nothing.
It was typechecked, which is why this went unnoticed. Confirmed the rules
actually fire on it rather than the file being silently skipped. The
'abhishek' branch was the only entry that got this right.
3. docs/opencode-extension.md overstated the security argument. "The token would
sit in a 0644 opencode.json" is not a point against a `type: local` stdio
bridge, which needs no token at all because it reads your own credentials.
That reason is a consequence of having picked the HTTP gateway, not a
justification for it. The docs now lead with the real reason — live
re-pointing without a restart — and state the trade honestly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
211 lines
9.7 KiB
Markdown
211 lines
9.7 KiB
Markdown
# 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`.
|