/** * mcpctl opencode server plugin — mounts the active project's MCP gateway. * * Installed by `mcpctl config opencode` into opencode's auto-discovered * `plugin/` directory (`~/.config/opencode/plugin/mcpctl.ts`), where it is * loaded at startup. It mounts the ACTIVE mcpctl project's MCP gateway as a * remote MCP server, so opencode's own MCP client exposes the project's tools * natively (`mcpctl_*`). * * WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json: * 1. The gateway needs an `Authorization: Bearer ` header — a * secret that does not belong in a mode-0644 config file users paste into * bug reports. `~/.mcpctl/opencode-state.json` is 0600 like every other * mcpctl credential. * 2. Switching projects must work WITHOUT restarting opencode. The server * exposes `POST /mcp` (add) and `POST /mcp/{name}/disconnect`, so the * mount can be re-pointed live; an `mcp` block in opencode.json cannot. * * It exists so headless runs (`opencode run ...`), which load no TUI plugins at * all, still get the active project's tools; the TUI plugin * (`mcpctl-opencode-tui.tsx`) drives the switch and the on-screen indicator. * * Only Node builtins + the plugin API are imported. */ import type { Plugin, PluginModule } from '@opencode-ai/plugin'; import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; /** MCP server name the active project is mounted under. Constant on purpose. */ const SERVER_NAME = 'mcpctl'; interface OpencodeState { project?: string; gatewayUrl?: string; tokens?: Record; } function statePath(): string { return join(homedir(), '.mcpctl', 'opencode-state.json'); } async function readState(): Promise { try { const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; return typeof parsed === 'object' && parsed !== null ? parsed : {}; } catch { return {}; } } /** Proxy MCP URL for a project on the gateway. */ function projectUrl(gatewayUrl: string, project: string): string { return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; } const server: Plugin = async ({ client }) => { /** * The (url, token) target this process last registered. * * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal * binds a gated project's unlocked state to the `mcp-session-id` of that * connection. Re-adding an unchanged config every turn would therefore drop * the gate opened by `begin_session` and re-lock the project mid-conversation. * So we only call `add` when the target actually changed — or when the mount * is not connected, where reconnecting is the whole point. */ let mounted: string | null = null; async function mount(): Promise { const state = await readState(); const project = state.project; const gatewayUrl = state.gatewayUrl; if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return; const token = state.tokens?.[project] ?? ''; const url = projectUrl(gatewayUrl, project); const target = `${url}\u0000${token}`; if (mounted === target && (await isConnected())) return; const headers: Record = {}; if (token !== '') headers['Authorization'] = `Bearer ${token}`; await client.mcp.add({ body: { name: SERVER_NAME, config: { type: 'remote', url, headers, enabled: true, timeout: 120_000, }, }, }); mounted = target; } /** Is our mount currently up? Unknown/unreachable counts as "not connected". */ async function isConnected(): Promise { try { const res = await client.mcp.status(); return res.data?.[SERVER_NAME]?.status === 'connected'; } catch { return false; } } /** * `mount`, serialised and never throwing. * * Serialised because the two callers below can overlap — the event stream is * chatty and a message can land while a mount is still connecting — and two * concurrent `mcp.add` calls would race to register the same name. * * Never throwing because an unreachable gateway must degrade to "no mcpctl * tools", not to "opencode fails to start". */ let inflight: Promise | null = null; function ensureMounted(): Promise { inflight ??= mount() .catch(() => { /* best-effort */ }) .finally(() => { inflight = null; }); return inflight; } // Deliberately NOT mounted here. Plugin setup runs before the server accepts // connections, and `client.mcp.add` calls back into that same server — // awaiting it at this point hangs opencode on a blank screen before the TUI // ever draws. Both hooks below fire only once the server is live. return { /** First contact: mount as soon as the server is up. */ event: async (): Promise => { await ensureMounted(); }, /** * Re-assert the mount before every user turn. * * `mcpctl config opencode --project X` (run from a shell, or by the TUI * switcher) rewrites the state file underneath us. Re-reading here is what * makes an external switch take effect on the next message instead of on * the next restart. When nothing changed this is one state-file read — the * mount hook won't re-register an unchanged mount. */ 'chat.message': async (): Promise => { await ensureMounted(); }, }; }; export default { id: 'mcpctl', server, } satisfies PluginModule & { id: string };