feat(opencode): native opencode addon — /mcpctl switcher, live project switching, footer indicator
Some checks failed
CI/CD / lint (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / typecheck (pull_request) Successful in 3m8s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / typecheck (pull_request) Successful in 3m8s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Implement mcpctl as an opencode addon mirroring the pi/prime-agent integrations. Mounts the active project's MCP gateway through opencode's own live MCP API, so switching projects needs no restart and opencode.json is never touched (the bearer token stays in a 0600 state file). - server plugin (plugin/mcpctl.ts): headless mount under a stable 'mcpctl' name, re-asserted on first contact + before each turn; skips re-registering an unchanged mount so a gated project's begin_session state survives. - TUI plugin (mcpctl/mcpctl-tui.tsx): /mcpctl filterable picker (live switch), /mcpctl-status, /mcpctl-skills, and a mcpctl:<project> footer indicator right of the model name, directly above the token counter. - mcpctl config opencode: mint/reuse project mcptoken, install + register plugins, write 0600 state, write marker, sync skills. - skills sync --agent opencode: new shared-tree target (XDG-aware). - shared credential plumbing lifted from config prime-agent and parameterised by agent so both hosts share mint/reuse/retire logic. - embedded-source generator + freshness test; typechecked against the real @opencode-ai/plugin types (1.18.15); unit tests for settings, order, embed. typecheck (incl. opencode-ext) and the full cli suite (619 tests) pass.
This commit is contained in:
154
src/opencode-ext/mcpctl-opencode.ts
Normal file
154
src/opencode-ext/mcpctl-opencode.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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 <mcpctl PAT>` 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<string, string>;
|
||||
}
|
||||
|
||||
function statePath(): string {
|
||||
return join(homedir(), '.mcpctl', 'opencode-state.json');
|
||||
}
|
||||
|
||||
async function readState(): Promise<OpencodeState> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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<boolean> {
|
||||
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<void> | null = null;
|
||||
function ensureMounted(): Promise<void> {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
await ensureMounted();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
id: 'mcpctl',
|
||||
server,
|
||||
} satisfies PluginModule & { id: string };
|
||||
Reference in New Issue
Block a user