117 lines
4.5 KiB
Markdown
117 lines
4.5 KiB
Markdown
|
|
# 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 <mcpctl PAT>` 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:<project>`** 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 <name>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`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).
|