Files
mcpctl/docs/opencode-extension.md
Michal be2a5cb189 feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator
Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills
sync target, so an mcpctl project can be switched from inside opencode's TUI
and the active one is visible at a glance.

Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into
the host's config. opencode exposes an HTTP API for its own MCP registry
(`POST /mcp`), so the project is mounted through the running app:

  - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a
    mode-0644 opencode.json users paste into bug reports;
  - switching projects takes effect on the next turn, with no restart.

Inside opencode:
  /mcpctl         filterable project picker; switches live
  /mcpctl-status  active project, mount state, gateway URL
  /mcpctl-skills  re-sync this project's skills
  plus a `mcpctl:<project>` indicator in the prompt footer, next to the model
  name and one line above the token counter.

Design notes:
  - the MCP server is registered under a constant name, so tools keep a stable
    `mcpctl_*` prefix and opencode's per-request tool resolution shows the new
    project's tools by itself — no "your old tool names are dead" message to
    the model, unlike the pi extension;
  - an unchanged mount is never re-registered: mcp.add rebuilds the connection
    and mcplocal binds a gated project's unlocked state to that connection's
    mcp-session-id, so re-adding would re-lock a project begin_session had just
    opened;
  - the server plugin does not mount during setup — setup runs before the
    server accepts connections and mcp.add calls back into it, which hangs
    opencode on a blank screen before the TUI draws;
  - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token
    minting, state and skills stay in one place;
  - no usable credential aborts non-zero with the state file untouched, so a
    failed switch leaves the previous project working rather than swapping it
    for a mount that 401s.

`skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG
aware) with the same shared-tree semantics as pi and prime-agent. The
credential plumbing shared with `config prime-agent` is lifted to one place and
parameterised by agent rather than copied.

The plugin sources are embedded in the CLI (generated, freshness-tested) so an
installed binary with no source tree can provision them, and are typechecked
against the real @opencode-ai/plugin types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-08 21:03:53 +01:00

8.3 KiB
Raw Blame History

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 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.

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

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:

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:

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.