# mcpctl × opencode — native MCP integration ## Motivation opencode is a native MCP client, so mcpctl can mount a project's MCP servers without any bridge process. `mcpctl config opencode --project X`: 1. installs two plugins into opencode's config directory, and 2. provisions the bearer credential the gateway needs. Switching projects stays **live** — no restarting opencode — which is the difference between this and a static `mcp` block in `opencode.json`. ## How it works Both plugins read `~/.mcpctl/opencode-state.json` (0600): ```jsonc { "project": "monitoring", "gatewayUrl": "https://mcp.ad.itaz.eu", "tokens": { "monitoring": "mcpctl_pat_..." } } ``` `opencode.json` is **never touched**. The gateway needs an `Authorization: Bearer ` header — a secret that does not belong in a mode-0644 config file users paste into bug reports — and the mount has to be re-pointable at runtime, which a config file cannot do. ### Server plugin (`plugin/mcpctl.ts`) Auto-discovered by opencode; covers **headless** runs (`opencode run ...`), which load no TUI plugins. It mounts the active project as a remote MCP server under the constant name `mcpctl` via opencode's own live MCP API: - on first server contact (`event`), and - before every user turn (`chat.message`), re-reading the state file so a switch made externally takes effect on the next message. The mount is only re-registered when the `(url, token)` target actually changed — re-adding an unchanged config would rebuild the connection and drop mcplocal's gated `begin_session` state mid-conversation. Never throws: an unreachable gateway degrades to "no mcpctl tools", never to "opencode fails to start". ### TUI plugin (`mcpctl/mcpctl-tui.tsx`, registered in `tui.json`) Adds to the opencode TUI: - **`/mcpctl`** — switch the active project from a filterable picker (active project first; terms match name **or** description; a pre-filter prompt is offered when the list is long), matching the prime-agent switcher UX; - **`/mcpctl-status`** — show the active project and its MCP mount state; - **`/mcpctl-skills`** — re-sync this project's skills into opencode's skill dir; - a **`mcpctl:`** indicator in the prompt footer, immediately right of the model name and just above the token counter. The switch is delegated to the `mcpctl` CLI (`--skip-plugin --skip-marker`), so token minting, state and skills stay in the CLI; once the state file is updated the TUI re-points the live MCP mount through `api.client.mcp.add`. ## Deliverables | Artifact | Purpose | |----------|---------| | `src/opencode-ext/mcpctl-opencode.ts` | Server plugin (headless mount) | | `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin (`/mcpctl` switcher + footer indicator) | | `src/cli/src/utils/opencode-settings.ts` | CLI wiring: paths, `tui.json` registration, state file | | `mcpctl config opencode` | Provision token + install/register plugins + skill sync | | `docs/opencode-extension.md` | This document | ## Skills `mcpctl config opencode` (when given a project and an API client) syncs the project's skills into `~/.config/opencode/skill/`. Re-sync later with: ```bash mcpctl skills sync --agent opencode --project ``` `opencode` is a shared-tree target (like `prime-agent`/`pi`): its own flat skill tree and its own state file (`~/.mcpctl/skills-state-opencode.json`), no Claude integration. ## Layout & typechecking The plugins ship as *source*: they are embedded into the CLI (`src/cli/src/config/opencode-extension.ts`, generated by `scripts/generate-opencode-extension.ts`) and written verbatim into `~/.config/opencode/`. The CLI's own build never compiles them, so `src/opencode-ext/tsconfig.json` checks them against the **real** `@opencode-ai/plugin` types (dev dependency pinned to the opencode release they target) — the same guarantee `src/pi-ext/` gives. ```bash pnpm run typecheck # includes typecheck:opencode-ext ``` After editing `src/opencode-ext/*`, regenerate the embedded copy or the CLI will keep installing old sources: ```bash npx tsx scripts/generate-opencode-extension.ts ``` A test (`tests/config/opencode-extension-embed.test.ts`) fails if you forget. ## Authentication caveat The server and TUI plugins send the stored `mcpctl_pat_` project token as the bearer. A locally running `mcpctl-local` daemon does not authenticate `/projects/*`, so the header is simply ignored; an authenticated `mcplocal serve` accepts only `mcpctl_pat_` bearers (`mcpctl config opencode` mints one per project).