opencode-cc-tool
An MCP server that gives Claude Code a first-class tool for dispatching workto the opencode CLI: launch a background task, get a task handle backimmediately, poll status, and fetch the result. No hand-rolled tmux wrappers,no grepping logs.
Why
The using-opencode Claude Code skill's documented pattern wrapsopencode run in a detached tmux session, then polls withuntil ! tmux has-session ... and greps the log for completion markers(EXIT_CODE=[0-9], exiting loop, etc). That pattern exists because tmuxwas available, not because it's the right tool. It has two real problems.
- The dispatched process can see the tmux session managing it. Inpractice, a dispatched
opencode runwith bash access rantmux list-sessions, saw its own wrapping session, mistook it for aduplicate run of the same task, and burned several minutes in aself-referential polling loop. A retry with an explicit "don't touchtmux" instruction succeeded. - Completion detection string-matches raw logs. Markers like
EXIT_CODE=orStatus: DONEcan appear inside quoted or nested text(e.g. a sub-task that echoes another log) and produce false positives.The skill documents extensive workarounds for exactly this.
This server sidesteps both. It spawns opencode run directly as a childprocess, with no tmux and no shared session for it to enumerate, anddetermines completion from the child process's real exit event, not fromlog text.
Tools
opencode_dispatch(prompt, directory, model?, variant?, session_id?)
Starts opencode run --dir <directory> --auto --format json -- <prompt> asa background child process, with stdout and stderr redirected to a privateper-task log file. Returns a task summary immediately, including id,status: "running", pid, and logPath.
directorymust be an absolute path that exists.model: any validprovider/modelstring (runopencode modelsto listthem). Defaults toopenai/gpt-5.6-luna --variant high, mirroring the"recommended" tier in theusing-opencodeskill's Select Model table.Pass e.g.opencode-go/minimax-m3for the "economy" tier on high-volume,lower-stakes work.variant: reasoning effort override (high,max,minimal, etc.),applied only whenmodelis also given. The default model always useshigh.session_id: resume an existing opencode session (--continue --session <id>) instead of starting fresh. Get session ids from a prioropencode_resultoropencode_statusresponse.
opencode_wait(task_id, timeout_ms?)
Blocks until the task's real exit event fires, or timeout_ms elapses(capped at 45000 regardless of what's passed, to stay under Claude Code'sown 60s default MCP tool-call timeout), then returns the same status shapeas opencode_status. This is the closest available analog to the built-inAgent tool's auto-resume behavior: call once, get blocked, get a result,instead of looping on opencode_status yourself. If it returns withstatus: "running", the task simply outlived the cap; call it again.
opencode_cancel(task_id, grace_ms?)
Stops a running task: sends SIGTERM to the task's whole process group(not just the opencode process, so a subprocess it's mid-way throughrunning, like a long bash command, dies too), escalating to SIGKILL aftergrace_ms (default 5000) if it hasn't exited. Calling it on a task thatalready finished is a no-op that returns a note instead of an error. Thetask's status becomes "cancelled" once its exit event lands, distinctfrom "crashed".
opencode_status(task_id)
Returns { status: "running" | "done" | "crashed" | "cancelled" | "unknown", exitCode, signal, logPath, ... }. status comes from the childprocess's actual exit event (child.on("exit", ...)), not from parsingoutput. "unknown" appears only if the server process restarted while thetask was still running; see Limitations.
opencode_result(task_id)
Once a task is done or crashed, parses its log (opencode's own--format json NDJSON event stream, one JSON object per line) into twofields:
message: the model's final turn only, thetextevents belonging tothe messageID whosestep_finishreason was"stop". This is the actualanswer; narration from earlier steps lives innarrationinstead.narration: everytextevent across every step, in order, separated byblank lines. Useful when a run does several tool calls with commentary inbetween and you want the fuller picture, not just the closing line.
A single-step run (no tool calls) has message === narration. Also returnssessionId, tokens, and cost pulled from the step_finish events.Returns a polite "still running" message instead of a partial result ifcalled too early.
Naively joining every text event regardless of step (an earlier versionof this tool did exactly that) glues "I'm about to run ls" directly ontothe real answer with no separator, since opencode's steps look like text(narration) → tool_use → step_finish (reason: "tool-calls") → text(answer) → step_finish (reason: "stop"). Verified by hand: a promptasking opencode to ls and report a count produced two text events, oneper step; message now returns only the second.
opencode_list()
Lists every task known to this server process, newest first.
Why polling and waiting, not push notifications
The built-in Agent tool notifies Claude Code when a background subagentfinishes; this server can't replicate that for MCP tools in general,because the relevant MCP mechanism is either unsupported or explicitlyrejected as of mid-2026:
- Generic server-initiated
notifications/messagepushed into the model'scontext: closed as not planned by Anthropic(anthropics/claude-code#36665).MCP's request-response shape means a server can't interrupt the modelmid-turn to say "your task finished." - Channels (code.claude.com/docs/en/channels)are the real, shipped mechanism for pushing events into a live session,but they're a heavier fit than they first look: research preview,Anthropic-account auth only, built as a Bun plugin rather than a plainstdio MCP server, and only active when the session was launched with
claude --channels plugin:<name>@<marketplace>. Being registered viaclaude mcp add(how this server is set up) isn't enough on its own;channels are a separate registration path. Worth revisiting if this toolneeds true async push later, but out of scope for now.
opencode_wait is the practical middle ground: one blocking call thatresolves the moment the task's exit event fires, capped well under ClaudeCode's MCP tool-call timeout so it degrades to a clean "still running"rather than an error. It gets Agent-tool-like ergonomics (dispatch, thenone call that "just returns when it's done") without depending on aresearch-preview feature.
Design notes
- Why
--format jsoninstead of the default formatted output.opencode's default text output mixes ANSI banners and step formattinginto the reply, which is awkward to parse reliably.--format jsonemitsone JSON event per line (step_start,text,step_finish, ...) with astable schema, including thesessionIDneeded for--continue.Confirmed by hand:opencode run --format json -- "Reply with the word PONG and nothing else."produced clean NDJSON on stdout and emptystderr on success. - State directory. Defaults to
~/.opencode-cc-tool, computed viaos.homedir()rather than hardcoded, overridable withOPENCODE_CC_TOOL_STATE_DIR. Holdstasks.json(task metadata) andlogs/<task_id>.ndjson(raw opencode output per task). - Not tmux, but
detached: true, for a narrower reason than survival.The server holds a direct reference to the child and listens on itsexitevent regardless ofdetached; that part doesn't need detaching.detached: truematters foropencode_cancel: it makes the child itsown process group leader (pgid === pid), soopencode_cancelcansignal the whole group withprocess.kill(-pid, ...)and reach asubprocessopencodespawned (e.g. a bash command it's running), notjust theopencodeprocess itself. Withoutdetached: true, the childwould share this server's own process group, and a group-kill would risktaking the server down with it. Isolation from the orchestration layer,the actual bug this tool fixes, comes from not launching via tmux at all:the child has no session or pane totmux list-sessionsits way into,independent of thedetachedflag.
Limitations and follow-ups
- State survives only for the current server process's lifetime. If theMCP server process restarts while a task is still running, the newprocess has no child-process handle to listen for that task's
exitevent (that handle exists only in the process that calledspawn). Onreload, the server relabels any task still marked"running"intasks.jsonas"unknown"instead of reporting a possibly-stale"running". The underlyingopencodeprocess, if still alive, keepsrunning and writing its log: inspect thelog file directly, or runopencode session list, but this server won'tre-attach a status watcher to it. A follow-up could periodically recheckunknowntasks' PIDs and tail their logs for a trailingstep_finishasa secondary signal, but that reintroduces string/heuristic matching forexactly the crash-recovery edge case, so it's left out for now ratherthan done half right. - No log rotation or cleanup:
logs/grows unbounded. Fine for interactiveuse; long-lived automation would want a retention policy.
Setup
cd /workspace/opencode-cc-tool
npm install
Register with Claude Code
claude mcp add opencode-cc-tool -- node /workspace/opencode-cc-tool/src/server.js
Use -s user instead of the default -s local scope to make it availablein every project, or -s project to check a .mcp.json entry into aspecific repo. To override the state directory:
claude mcp add opencode-cc-tool -e OPENCODE_CC_TOOL_STATE_DIR=/some/path -- node /workspace/opencode-cc-tool/src/server.js
Verify registration:
claude mcp list
claude mcp get opencode-cc-tool
Smoke tests (standalone, no Claude Code needed)
Each drives the server over stdio using the MCP SDK's Client, exactly asClaude Code would.
node src/smoke-test.js /workspace/opencode-cc-tool # dispatch, poll status, fetch result; expects PONG
node src/cancel-smoke-test.js /workspace/opencode-cc-tool # dispatch a sleep, cancel it, confirm the process group is gone
node src/wait-smoke-test.js /workspace/opencode-cc-tool # opencode_wait resolving early and hitting its cap
Each prints a ... SMOKE TEST PASSED or FAILED line and exitsaccordingly.