cyberine
UsageRun recipes

Expose Cyberine as an MCP server — npm run mcp

npm run mcp starts Cyberine as an MCP server (stdio transport) so any MCP host (Claude Code, Cursor, Claude Desktop) can drive it. The server declares a short instructions string (a few lines: what Cyberine is, to run cyberine guide for the command map, and to ask before anything that spends) — this is deliberately tiny because it costs tokens in every connected session, on top of whatever system prompt the host itself renders from the orchestrate tool description below. Tools exposed:

  • orchestrate — dispatch one prompt to every configured provider in parallel and judge the results (consensus / llm-judge / adversarial). Non-blocking (T650): returns immediately with a runId once the dispatch has been recorded — it does NOT wait for the run to finish. See "Non-blocking orchestrate" below for how to stream progress, poll the outcome, and cancel. Args: prompt, optional authToken (see auth gate below). T704: returns a real error (isError: true, status absent) instead of status: "dispatched" when the daily spend cap already blocks dispatch or zero adapters are configured — see "Non-blocking orchestrate" below.
  • research — dispatch a multi-agent web-research sweep on a question: refine it, fan out N parallel search-angle workers, adversarially verify every claim, then synthesize a cited markdown report (the same researchWorkflow.ts cyberine research runs). THIS SPENDS QUOTA — every step calls an LLM, and higher depth fans out to more workers. Non-blocking, same shape as orchestrate: returns immediately with a runId. See "Non-blocking research" below. Args: question, optional depth (quick|deep|max, default deep), optional authToken (see auth gate below).
  • send_message — publish a message to a run or chat inbox on the message bus. Args: to_kind (run|chat), to_id (runId / chatSessionId), text, optional topic (note default, or steer to guide a running run between steps — the bus-level equivalent of the chat /steer command), optional authToken (see auth gate below).
  • poll_inbox — snapshot-read a run/chat/broadcast inbox; optional sinceMs to filter to recent messages. Returns the envelopes as JSON. Args: kind, id, optional sinceMs, optional authToken.
  • query_runs — discover runs across all projects, newest first; optional query substring (matches task preview / workflow / cwd / state) and limit (default 20). Args: optional query, optional limit, optional authToken.
  • list_team — return the live presence roster: all processes (runs, chat sessions, workers, MCP servers) that have announced via the Cyberine presence layer and are still within their heartbeat TTL. Each record includes addr (kind + id), repoCwd, capabilities, pid, startedAt, and lastBeat. Optional joinRuns=true merges GlobalRegistry run metadata (task preview, workflow name, state) for run-kind entries. Presence files live under <CYBERINE_HOME>/bus/presence/; stale (no heartbeat within ttlMs, default 60 s) and tombstoned records are excluded. Args: optional joinRuns, optional authToken.
  • send_to_team — broadcast a message to a named team's team-<name> topic. Args: team, text, optional topic, optional authToken.
  • send_to_role — resolve live presence by {team, role, name} (all optional, ANDed) and publish to every matching address. Args: team, role, name, text, optional topic, optional authToken.
  • inspect_run — return a structured candidate-comparison view for a run: all steps, per-step worker candidates (output preview, tokens, duration), the judge verdict and rationale, and the winning output. Also surfaces any operator decision already recorded. Cross-project read (like query_runs/list_team) — not scoped to this server's own cwd. Args: runId (run UUID), optional authToken.
  • approve_run — record an operator approval decision for a run or step. Persisted to approval.json in the run directory and embedded in run.json. Scoped: rejects a runId whose registry cwd doesn't match this MCP server's own process cwd (T602 — a server only approves/rejects runs from the project it was started in). Args: runId, optional comment, optional stepId (targets a specific step winner; omit for the whole run), optional authToken.
  • reject_run — record an operator rejection decision. Same args, persistence, and project scoping as approve_run.

Auth gate (T602, extended by T612). Stdio MCP has no per-call header channel, so the token travels as a tool argument (or, for the one resource read, a query parameter — see below) rather than an Authorization header, checked with a constant-time compare. When CYBERINE_MCP_AUTH_TOKEN is unset (the default), the server stays fail-open — a plain stdio MCP server is a direct child of the connecting host, sharing its environment, so there is no network exposure to defend against by default. Set CYBERINE_MCP_AUTH_TOKEN to opt into fail-closed.

T602 originally gated only the five mutating/steering tools (send_message, send_to_team, send_to_role, approve_run, reject_run). T612 extended the gate to cover every surface that either mutates state, spends money, or leaks cross-project state, and kz5sr added research (the same non-blocking, quota-spending shape as orchestrate) to that gated set on arrival — that is now ELEVEN tool calls plus the one resource read, not five: orchestrate, research (both dispatch a real, possibly paid, provider call), send_message, poll_inbox, query_runs, list_team, send_to_team, send_to_role, inspect_run, approve_run, reject_run, and the cyberine://runs/{runId} resource read. Once CYBERINE_MCP_AUTH_TOKEN is set, every one of those must supply a matching authToken — as a tool argument for all eleven tools, or as a ?authToken= query parameter on the resource URI (a resources/read request has no argument channel at all, only a URI) — or the call/read is rejected with unauthorized. Every envelope these tools publish now carries the connecting MCP client's identity (clientInfo.name@version from the initialize handshake) as from.id instead of the previous hardcoded 'mcp', and approve_run/reject_run stamp the same identity onto OperatorDecision.decidedBy.

Resources (URI-addressable, same data as inspect_run):

  • cyberine://runs/{runId} — read the full inspection view for a run as application/json. MCP clients that support resource reading can fetch this URI directly without invoking a tool. When CYBERINE_MCP_AUTH_TOKEN is configured, append a matching token as a query param: cyberine://runs/{runId}?authToken=<token>.
  • resources/list enumerates real run instances (backed by GlobalRegistry.listRuns(), capped at 200, newest-updated first) — each entry's name is the run's task preview and description carries workflow/state/cwd, so a client can browse before picking a runId.
npm run mcp     # serve on stdio; point your MCP host at this command
CYBERINE_MCP_AUTH_TOKEN=$(openssl rand -hex 24) npm run mcp   # opt into the auth gate

npm run selftest:mcp-handshake   # verify the server boots and answers over real stdio

SDK 2.x and the handshake smoke (T649)

Since 2026-09-05 both MCP surfaces run on the split 2.x packages — the server on @modelcontextprotocol/server (with StdioServerTransport behind its /stdio subpath) and McpToolAdapter on @modelcontextprotocol/client (StdioClientTransport likewise behind /stdio). The old single @modelcontextprotocol/sdk package is deliberately still installed: two smoke scripts (scripts/t612-smoke.ts, scripts/t704-mcp-env-echo-server.ts) stay on the 1.x client on purpose, so every sweep proves a 1.x client still talks to the 2.x server. That is a cross-era interop control, not leftover drift — do not "tidy" it away.

npm run selftest:mcp-handshake drives the real server over real stdio and asserts on the bytes that come back: initialize answers, serverInfo.name is cyberine, a 2025-era client still negotiates a 2025-era revision, the tools capability is advertised, and every registered tool (now eleven, including research — kz5sr) lists with non-empty inputSchema properties. It exists because npm run typecheck passes on both sides of that migration — a wire-protocol break is invisible to the compiler.

Known limitation, measured rather than assumed: this smoke does not discriminate a raw Zod shape from z.object(...) on inputSchema. The migration converted all ten tools to z.object(...) because that is the documented 2.x form, but a positive control (revert one tool to the bare shape, drive the server, read tools/list) returned identical properties — SDK 2.0.0 still normalises the raw shape at runtime. So that conversion is forward-compat hygiene with no regression test behind it. The smoke also covers only the 2025-era path; nothing here exercises the 2026-07-28 server/discover flow or the per-request _meta envelope.

Non-blocking orchestrate (T650)

orchestrate used to await the full multi-agent-dispatch-plus-judge run before replying — the calling MCP host had no progress feedback and no way to cancel mid-flight. It now returns as soon as the run is durably recorded (src/orchestrator/orchestrateAsync.ts's startBackgroundOrchestrate, a single RunStore write), and the dispatch continues in the background:

{
  "runId": "5b1e...-uuid",
  "status": "dispatched",
  "detail": "dispatched to configured providers; poll inspect_run(runId=\"...\") or query_runs() for the outcome",
  "pollWith": { "tool": "inspect_run", "arguments": { "runId": "5b1e...-uuid" } },
  "cancelWith": {
    "description": "write this JSON to the run control file to request cancellation",
    "controlFile": ".local/runs/5b1e...-uuid/control.json",
    "body": { "action": "stop", "target": { "type": "run" } }
  }
}

BREAKING CHANGE for existing MCP hosts: the tool's response shape changed. It previously returned the full { task, results, verdicts } outcome synchronously; it now returns the { runId, status, ... } envelope above immediately, before dispatch settles. A host that expected the old synchronous shape must switch to polling for the outcome (below).

T704: a blocked/impossible dispatch returns a real error, not "dispatched". Two conditions that Orchestrator.dispatch() itself would reject synchronously — the daily spend cap already blocking dispatch, or zero adapters configured (Orchestrator requires at least one agent) — are now pre-checked in the tool handler BEFORE handing off to the background transport, so the caller gets { "error": "..." } with isError: true immediately instead of a status: "dispatched" envelope for a dispatch that either never started or immediately failed with nothing to poll for. Both checks are read-only (no reservation held), so they narrow but do not fully close the same admission race Orchestrator.dispatch()'s own reserveDailyBudget() closes for a synchronous caller — a dispatch that races past this pre-check can still be rejected once it actually reaches Orchestrator.dispatch(), in which case it settles to the background run's own 'error' state (pollable via inspect_run) rather than being caught here.

  • Progress. Pass a JSON-RPC _meta.progressToken on the tools/call request (the MCP SDK's Client.callTool(params, resultSchema, { onprogress }) option does this for you) to receive standard notifications/progress events — one per dispatched-agent lifecycle tick (started/text/tool/usage/done/error) plus one on final settlement. This is the stable MCP 1.x notification, not the (not-yet-stable) MCP Tasks extension — no SDK bump required.
  • Poll outcome. Call the EXISTING inspect_run tool with the returned runId (or query_runs/GET cyberine://runs/{runId} — any reader of RunStore/GlobalRegistry sees this run exactly like a WorkflowEngine-authored one). state moves running -> done / error / cancelled; steps[0].candidates carries one candidate per dispatched provider, and steps[0].verdict / winnerPreview carry the judged winner once decided (a MAJORITY consensus preferred, else the first non-consensus verdict with a winner — same selection WorkflowEngine itself uses, see "Which verdict wins a step").
  • Cancel. Write {"action":"stop","target":{"type":"run"}} to <cwd>/.local/runs/<runId>/control.json — the SAME control-channel protocol the fleet TUI's stop key already uses (src/tui/control.ts's stopRun(runId), callable directly from Node). The background dispatch polls this file every 500 ms (mirrors WorkflowEngine's own cadence); once observed, the in-flight orchestrate() call is aborted and the run settles to cancelled.
  • Known limitations / deferred work: query_runs' substring filter matches the live taskPreview, itself derived from Run.detail — that field holds the original prompt only while the run is still running; once it settles, detail is overwritten with an outcome summary (matching the convention WorkflowEngine's own runs already follow), so a substring search for the original prompt text stops matching after completion. Search by workflowName (mcp-orchestrate), cwd, or the runId itself instead. There is also no dedicated cancel_run MCP tool — cancellation goes through the raw control-file write above, not a typed tool call; and progress ticks are lifecycle-level (per dispatched agent), not token-level streaming of assistant text.

Non-blocking research (kz5sr)

research wires the standing research workflow (src/engine/researchWorkflow.ts — plan -> search fan-out -> verify claims -> synthesize report, the same one cyberine research runs) onto the MCP server, gated behind the same checkAuth every other spend/state surface uses. It returns immediately with a runId, the same shape as orchestrate above:

{
  "runId": "5b1e...-uuid",
  "status": "dispatched",
  "depth": "deep",
  "detail": "research run dispatched (depth=deep); poll inspect_run(runId=\"...\") or query_runs() for the outcome",
  "reportPath": "/abs/path/.local/research/<slug>.md",
  "pollWith": { "tool": "inspect_run", "arguments": { "runId": "5b1e...-uuid" } }
}

Why it cannot reuse orchestrateAsync's in-process pattern. orchestrate's non-blocking transport (src/orchestrator/orchestrateAsync.ts) runs the dispatch in-process because its task is a plain function argument. researchWorkflow.ts instead reads CYBERINE_TASK / CYBERINE_DEPTH / CYBERINE_SKEPTICS at MODULE LOAD TIME (top-level consts), so importing it more than once in the same process replays the FIRST call's question — invisible to cyberine research (a fresh process every invocation) but fatal to the MCP server, which is long-lived for the whole client connection. research therefore spawns a fresh DETACHED CHILD PROCESS per dispatch (src/engine/researchAsync.ts's startBackgroundResearch, running src/cyberine-research-run.ts) instead of dispatching in-process — the same shape launchBackgroundRun uses for cyberine run --bg / chat /run, minus the board-task-brief / memory / rules preambles that path injects (not relevant to a research question). The run still lands in the normal RunStore / GlobalRegistry, so inspect_run, query_runs, and cyberine agents all see it exactly like any other run.

  • Poll outcome. Call inspect_run(runId) — the synthesize step's winnerOutput is the machine-readable { report, markdown, verifiedCount, droppedCount, ... } envelope cyberine research itself writes to disk; once the run is terminal the same markdown is also written to reportPath (default <cwd>/.local/research/<slug>.md, mirroring cyberine research's own default).
  • Depth. quick = 3 angles / 2 skeptics per claim, deep (default) = 5/3, max = 7/5 — same presets as cyberine research --depth.
  • Cancel. No dedicated cancel path yet — the child process is a plain detached spawn, not registered with the control.json protocol orchestrate's background dispatch polls. Killing the run's enginePid (visible in run.json) is the only stop lever today.
  • Known limitations / deferred work: the default web-search backend is a Claude seat since kx1bv (2026-09-20), but no research run has gone end to end yet (kf15g), so the actual dispatch (question -> search fan-out -> verify -> synthesize) has not been exercised end to end; only the wiring was verified — the tool lists in the MCP handshake (selftest:mcp-handshake), the auth gate rejects an unauthenticated call (selftest:mcp-auth-gate), and startBackgroundResearch is reachable and spawns the expected child command. Spend/daily-cap gating is inherited from WorkflowEngine.run() itself (the same guard cyberine research and every other engine caller goes through) — this tool does not duplicate orchestrate's own pre-check pass, so a blocked dispatch surfaces once the run settles to an error state rather than as an immediate isError reply.

The bus tools share the same <CYBERINE_HOME>/bus/ mailboxes the chat and engine use, so an external host can steer a run or read its progress alongside the in-process TUI. They degrade gracefully: send_message/poll_inbox no-op (ok:false / []) when CYBERINE_BUS_BACKEND=none.

Publish is decoupled from receiver liveness. FileMessageBus.publish() is a pure JSONL append to the target's mailbox file (src/store/messageBus.ts:172-180) and inbox() / poll_inbox just read that same file back (src/store/messageBus.ts:234-248) — neither checks whether anything is still alive to receive it. send_message/bus_send to an already-finished or crashed run's address still lands on disk and is readable via poll_inbox afterward; there is just no live worker loop left to drain-and-react to it. Useful for after-the-fact inspection of what was sent, but don't expect a reply from a target that has already exited.

Caller identity on bus sends and decisions (kenv8)

Every send_message / send_to_team / send_to_role stamps from.id, and approve_run / reject_run stamp decidedBy, with the calling MCP client's name@version. Since SDK 2.x the identity is read per request from the _meta envelope (io.modelcontextprotocol/clientInfo, surfaced as ctx.mcpReq.envelope) and only falls back to the deprecated initialize-scoped getClientVersion() for clients that send no envelope -- so a 2025-era client and a 2026-07-28 client stamp the same shape, and a client that changes identity mid-connection is attributed per call. unknown-mcp-client means the client declared no clientInfo anywhere (the spec permits it). npm run selftest:mcp-handshake drives both cases against the real server and reads the id back from the bus file the send landed in.

Known Limitations and Deferred Work: the envelope value is self-reported and meant for display and attribution, never for authorization -- CYBERINE_MCP_AUTH_TOKEN remains the only auth gate.