Repository navigation
feat(mcpx): opencode v2 plugin core — {id, setup}, namespaced tools, ctx.storage (#1858) #2489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
984df4a
b167499
1653bbc
64e3f22
6f09199
379a993
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /** | ||
| * The system-prompt paragraph, via session.hook("context") (design doc | ||
| * §2.2). The hook fires per turn, so the append is idempotent: the paragraph | ||
| * carries a marker and is added only when no existing part contains it. | ||
| */ | ||
| export const CONTEXT_MARKER = "mcpx:session-context"; | ||
|
|
||
| export const CONTEXT_PARAGRAPH = | ||
| `[${CONTEXT_MARKER}] mcpx leases MCP servers per session through a local ` + | ||
| `daemon; the mcpx_* tools and shell environment already know this session's ` + | ||
| `identity, so never pass a session id to a tool or ask the user for one.`; | ||
|
|
||
| export type SystemPart = { type?: string; text?: string } | string; | ||
|
|
||
| export type ContextHookInput = { | ||
| sessionID: string; | ||
| system: SystemPart[]; | ||
| }; | ||
|
|
||
| /** Append the paragraph once; returns true when it appended. */ | ||
| export function appendContextOnce(input: ContextHookInput): boolean { | ||
| const already = input.system.some((part) => | ||
| (typeof part === "string" ? part : (part.text ?? "")).includes(CONTEXT_MARKER), | ||
| ); | ||
| if (already) return false; | ||
| input.system.push({ type: "text", text: CONTEXT_PARAGRAPH }); | ||
| return true; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /** | ||
| * The daemon core is harness-free and shared with v1: discovery, the /v1 | ||
| * socket client, ranking and the remembered-target types all live in | ||
| * ../mcpx/daemon.ts and are imported unchanged (design doc §5). This module | ||
| * exists so the v2 tree names its dependency in one place. | ||
| */ | ||
| export * from "../mcpx/daemon.ts"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| /** | ||
| * mcpx for opencode v2 — the { id, setup } module (design doc §1). | ||
| * | ||
| * Setup wires the whole core: the remembered-daemon read (ctx.storage), | ||
| * the idempotent system-prompt paragraph (session context hook), the | ||
| * once-per-session full-environment send, execute.after timing records, | ||
| * and tool registration (tools.ts: tool.transform under the mcpx | ||
| * namespace, so the model sees the v1 names — mcpx_exec and friends). | ||
| * The v1 tree stays beside this one, untouched, until v2 is what runs. | ||
| */ | ||
| import { targetKey, type Target } from "../mcpx/daemon.ts"; | ||
| import { appendContextOnce, type ContextHookInput } from "./context.ts"; | ||
| import { resolveOptions, type Options } from "./options.ts"; | ||
| import { readRememberedDaemon, type StorageLike } from "./remember.ts"; | ||
| import { createSessionEnv } from "./session-env.ts"; | ||
| import { | ||
| createTimingLog, | ||
| type ExecuteAfterInput, | ||
| type ToolTimingRecord, | ||
| } from "./timing.ts"; | ||
| import { | ||
| createToolRuntime, | ||
| registerTools, | ||
| type ToolDomainLike, | ||
| type ToolRuntime, | ||
| } from "./tools.ts"; | ||
|
|
||
| /** The slice of the v2 setup Context this core uses (promise/plugin.ts). */ | ||
| export type McpxV2Context = { | ||
| storage: StorageLike; | ||
| options?: Options; | ||
| serverUrl?: string; | ||
| directory?: string; | ||
| session: { | ||
| hook(name: "context", fn: (input: ContextHookInput) => void): void; | ||
| }; | ||
| tool: ToolDomainLike & { | ||
| hook(name: "execute.after", fn: (input: ExecuteAfterInput) => void): void; | ||
| }; | ||
| }; | ||
|
|
||
| export async function setupMcpxV2(ctx: McpxV2Context, rawOptions?: Options): Promise<void> { | ||
| const options = resolveOptions(rawOptions, process.env); | ||
| const directory = ctx.directory ?? process.cwd(); | ||
| const sessionEnv = createSessionEnv({ | ||
| serverUrl: ctx.serverUrl, | ||
| baseEnv: process.env, | ||
| }); | ||
| // The daemon endpoint rides in the session environment, as v1's | ||
| // shell.env hook injected it: a shell `mcpx` command must talk to the | ||
| // same daemon the plugin tools use, not walk its own ladder to a | ||
| // possibly different one. MCPX_DAEMON_ENDPOINT, not MCPX_SOCKET — | ||
| // the endpoint is only read when connecting, while the socket also | ||
| // decides where a newly started daemon binds (v1's reasoning, | ||
| // mcpx-session.ts). Sessions are tracked so a daemon_select later in | ||
| // the session can refresh the ones already sent. | ||
| const envSessions = new Set<string>(); | ||
| let activeEndpoint: string | undefined; | ||
| const sessionVars = (sessionID: string): Record<string, string> => { | ||
| const vars: Record<string, string> = { | ||
| MCPX_SESSION_ID: sessionID, | ||
| MCPX_DIRECTORY: directory, | ||
| }; | ||
| if (activeEndpoint !== undefined) vars.MCPX_DAEMON_ENDPOINT = activeEndpoint; | ||
| return vars; | ||
| }; | ||
| const noteTarget = (target: Target): void => { | ||
| const endpoint = targetKey(target); | ||
| if (endpoint === activeEndpoint) return; | ||
| activeEndpoint = endpoint; | ||
| for (const sessionID of envSessions) { | ||
| void sessionEnv.update(sessionID, sessionVars(sessionID)).catch(() => false); | ||
| } | ||
| }; | ||
| // The runtime is created before the timing log: the log's sink | ||
| // delivers through it (below), and the closure only runs once a tool | ||
| // has executed — long after both exist. | ||
| let runtime: ToolRuntime | undefined; | ||
| // toolTiming's sink is v1's path: the record goes to the daemon's | ||
| // durable log (`daemon.record`, event "harness.tool"), so one | ||
| // `mcpx stats` / mcpx_observe covers harness tool calls too. Best | ||
| // effort exactly as v1: with no daemon the record is dropped rather | ||
| // than spawning a process per tool call, and a failed send never | ||
| // fails the tool call it describes. | ||
| const deliverTiming = (record: ToolTimingRecord): void => { | ||
| void (async () => { | ||
| const client = await runtime?.daemon().catch(() => undefined); | ||
| if (!client) return; | ||
| await client | ||
| .record({ | ||
| event: "harness.tool", | ||
| tool: record.tool, | ||
| session: record.sessionID, | ||
| status: record.status, | ||
| }) | ||
| .catch(() => undefined); | ||
| })(); | ||
| }; | ||
| const timing = createTimingLog(deliverTiming); | ||
| // toolTiming comes from the resolved options (host option, then the | ||
| // environment, then the v1 default false) — never a bare ?? true: a | ||
| // user who turned timing off gets no hook, and no records array | ||
| // growing for the life of the plugin instance. | ||
| const toolTiming = options.toolTiming ?? false; | ||
|
|
||
| // Best-effort warm read: a remembered daemon for this directory means the | ||
| // first discovery in the session can skip the ladder's cold rungs. The | ||
| // storage read must never fail setup. | ||
| void readRememberedDaemon(ctx.storage, directory).catch(() => undefined); | ||
|
|
||
| ctx.session.hook("context", (input) => { | ||
| // The paragraph is gated on plugin.instructions, off by default, | ||
| // exactly as v1's system transform was: prompt content is paid on | ||
| // every request, so it is opt-in help. The environment send below | ||
| // is NOT gated — session identity is plumbing, not help. | ||
| if (options.instructions) appendContextOnce(input); | ||
| // First sighting of a session is when its environment is sent — once, | ||
| // with the full environment (session-env.ts explains why full). | ||
| envSessions.add(input.sessionID); | ||
| void sessionEnv.ensure(input.sessionID, sessionVars(input.sessionID)).catch(() => false); | ||
| }); | ||
|
|
||
| if (toolTiming) { | ||
| ctx.tool.hook("execute.after", (input) => { | ||
| timing.hook(input); | ||
| }); | ||
| } | ||
|
|
||
| // Tools last, and never allowed to fail setup: a plugin that throws | ||
| // during load loses the session bridge and the environment send too, | ||
| // which are worth more than the tool surface. Registration does its | ||
| // own boot discovery for the daemon-tools default (tools.ts). | ||
| runtime = createToolRuntime({ | ||
| directory, | ||
| env: process.env, | ||
| options, | ||
| storage: ctx.storage, | ||
| onTarget: noteTarget, | ||
| }); | ||
| await registerTools(ctx.tool, runtime, { | ||
| toolsEnabled: options.tools ?? false, | ||
| daemonTools: options.daemonTools, | ||
| }).catch(() => []); | ||
| } | ||
|
Comment on lines
+139
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export default { | ||
| id: "mcpx", | ||
| setup: (ctx: McpxV2Context) => setupMcpxV2(ctx, ctx.options), | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /** | ||
| * Plugin options for the v2 tree: the v1 Options type ported from | ||
| * mcpx-session.ts, resolved the v1 way. | ||
| * | ||
| * Settings guard: TestThePluginReadsNoUndeclaredSetting scans plugin/**, | ||
| * so the v2 tree reads only names the registry or the script-env contract | ||
| * declares — the MCPX_PLUGIN_* names in internal/settings/plugin.go, plus | ||
| * MCPX_DAEMON_ENDPOINT / MCPX_ENDPOINT / MCPX_SOCKET from scriptenv.go, | ||
| * the same names v1 reads. The reads are | ||
| * property accesses (env.MCPX_PLUGIN_BIN and friends), never quoted | ||
| * literals: TestTheScriptEnvironmentIsWhatTheCodeSets treats a quoted | ||
| * MCPX_* name as a variable the plugin SETS into a child's environment, | ||
| * and these are reads, not sets. (An earlier draft listed the names in | ||
| * a quoted array and failed that test on all twelve.) | ||
| */ | ||
| import { goDurationMs, splitArgs } from "../mcpx/daemon.ts"; | ||
|
|
||
| export type Options = { | ||
| /** A daemon named outright. `unix:///path` or `http://host:port`. */ | ||
| endpoint?: string; | ||
| /** A socket named outright. */ | ||
| socket?: string; | ||
| /** The mcpx binary, or "" to forbid spawning one. */ | ||
| bin?: string; | ||
| /** Arguments to put before mcpx's own, as a vector or a string. */ | ||
| binArgs?: string[] | string; | ||
| /** Where the opt-in tools get their answers: auto, v1 or cli. */ | ||
| backend?: "auto" | "v1" | "cli"; | ||
| /** Default scope for a daemon choice made this session. */ | ||
| remember?: "session" | "until-gone" | "indefinite"; | ||
| /** How much to inject into shell environments. */ | ||
| env?: "minimal" | "standard" | "full"; | ||
| instructions?: boolean; | ||
| toolTiming?: boolean; | ||
| tools?: boolean; | ||
| /** Offer mcpx_daemon_status and mcpx_daemon_select. Default: when unclear. */ | ||
| daemonTools?: boolean; | ||
| /** Report ambiguity on tool results. Default on. */ | ||
| annotate?: boolean; | ||
| /** Override the "is anyone looking at this" guess. */ | ||
| headless?: boolean; | ||
| }; | ||
|
|
||
| const str = (v: unknown): string | undefined => | ||
| v === undefined || v === null || v === "" ? undefined : String(v); | ||
|
|
||
| /** | ||
| * The bin resolution only: nullish, the v1 way. An explicit "" is a | ||
| * decision — forbid spawning — not an absence: str() would collapse it | ||
| * to undefined and runBin would fall back to MCPX_PLUGIN_BIN or the | ||
| * default binary, spawning exactly what the user forbade. | ||
| */ | ||
| const binStr = (v: unknown): string | undefined => | ||
| v === undefined || v === null ? undefined : String(v); | ||
|
|
||
| /** The v1 truthy parse: an unrecognised value is undefined, not false. */ | ||
| const truthy = (v: unknown): boolean | undefined => { | ||
| if (v === undefined || v === null || v === "") return undefined; | ||
| if (typeof v === "boolean") return v; | ||
| const s = String(v).toLowerCase(); | ||
| if (s === "1" || s === "true" || s === "yes" || s === "on") return true; | ||
| if (s === "0" || s === "false" || s === "no" || s === "off") return false; | ||
| return undefined; | ||
| }; | ||
|
|
||
| /** | ||
| * Resolve raw options the v1 way — plugin option first, then the | ||
| * environment variable, then the caller's default — for the settings | ||
| * the v2 core needs. Unset fields stay undefined, except toolTiming, | ||
| * whose v1 default is false (opt-in): a resolved false is a decision, | ||
| * and index.ts registers no timing hook for it. | ||
| * | ||
| * `discoveryRetryMs` is not a v1 Options field; it is the resolved | ||
| * plugin.discoveryRetry setting (a duration the registry declares and | ||
| * v1 applied to its discovery ladder), carried beside the options | ||
| * because tools.ts's ladder is its only consumer in this tree. | ||
| * | ||
| * Still unresolved here, deliberately: env / headless. The v2 core | ||
| * has no consumer for them yet — env sized v1's shell.env payload | ||
| * (the v2 environment route sends the full env by design, §3), and | ||
| * headless gated v1's toasts (the v2 context slice has no TUI | ||
| * channel). Resolving a setting nothing reads would be the | ||
| * declared-but-unread bug in a new costume. (instructions was on | ||
| * this list until the tools slice: the context hook now gates its | ||
| * paragraph on it, exactly as v1's system transform did, so it is | ||
| * resolved above.) | ||
| */ | ||
| export type ResolvedOptions = Options & { discoveryRetryMs?: number }; | ||
|
|
||
| export function resolveOptions( | ||
| raw: Options | undefined, | ||
| env: Record<string, string | undefined>, | ||
| ): ResolvedOptions { | ||
| const o = raw ?? {}; | ||
| return { | ||
| endpoint: str(o.endpoint) ?? str(env.MCPX_DAEMON_ENDPOINT) ?? str(env.MCPX_ENDPOINT), | ||
| socket: str(o.socket) ?? str(env.MCPX_SOCKET), | ||
| bin: binStr(o.bin) ?? str(env.MCPX_PLUGIN_BIN), | ||
| binArgs: Array.isArray(o.binArgs) | ||
| ? o.binArgs | ||
| : typeof o.binArgs === "string" | ||
| ? splitArgs(o.binArgs) | ||
| : splitArgs(env.MCPX_PLUGIN_BIN_ARGS), | ||
| backend: (str(o.backend) ?? | ||
| str(env.MCPX_PLUGIN_BACKEND) ?? | ||
| "auto") as Options["backend"], | ||
| remember: (str(o.remember) ?? | ||
| str(env.MCPX_PLUGIN_REMEMBER) ?? | ||
| "session") as Options["remember"], | ||
| env: (str(o.env) ?? str(env.MCPX_PLUGIN_ENV)) as Options["env"], | ||
| tools: o.tools ?? truthy(env.MCPX_PLUGIN_TOOLS) ?? false, | ||
| instructions: o.instructions ?? truthy(env.MCPX_PLUGIN_INSTRUCTIONS) ?? false, | ||
| daemonTools: o.daemonTools ?? truthy(env.MCPX_PLUGIN_DAEMON_TOOLS), | ||
| annotate: o.annotate ?? truthy(env.MCPX_PLUGIN_ANNOTATE) ?? true, | ||
| toolTiming: o.toolTiming ?? truthy(env.MCPX_PLUGIN_TOOL_TIMING) ?? false, | ||
| discoveryRetryMs: goDurationMs(env.MCPX_PLUGIN_DISCOVERY_RETRY), | ||
| }; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.