187 lines
8.3 KiB
Markdown
187 lines
8.3 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`
|
|||
|
|
|
|||
|
|
Two reasons the project does not live in opencode's own config:
|
|||
|
|
|
|||
|
|
1. **The token.** The gateway needs `Authorization: Bearer <mcpctl PAT>`.
|
|||
|
|
`opencode.json` is a mode-0644 file people paste into bug reports;
|
|||
|
|
`~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential.
|
|||
|
|
2. **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.
|
|||
|
|
|
|||
|
|
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` | Filterable project picker; switches live |
|
|||
|
|
| `/mcpctl-status` | Active project, mount state, gateway URL |
|
|||
|
|
| `/mcpctl-skills` | Re-sync this project's skills |
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
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. On the home screen the prompt
|
|||
|
|
box is narrow, so a long project name wraps onto a second line; in a session
|
|||
|
|
(where the prompt is full width) it always fits on one.
|
|||
|
|
|
|||
|
|
### 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`.
|