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 arunIdonce 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, optionalauthToken(see auth gate below). T704: returns a real error (isError: true,statusabsent) instead ofstatus: "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 sameresearchWorkflow.tscyberine researchruns). THIS SPENDS QUOTA — every step calls an LLM, and higherdepthfans out to more workers. Non-blocking, same shape asorchestrate: returns immediately with arunId. See "Non-blockingresearch" below. Args:question, optionaldepth(quick|deep|max, defaultdeep), optionalauthToken(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, optionaltopic(notedefault, orsteerto guide a running run between steps — the bus-level equivalent of the chat/steercommand), optionalauthToken(see auth gate below).poll_inbox— snapshot-read arun/chat/broadcastinbox; optionalsinceMsto filter to recent messages. Returns the envelopes as JSON. Args:kind,id, optionalsinceMs, optionalauthToken.query_runs— discover runs across all projects, newest first; optionalquerysubstring (matches task preview / workflow / cwd / state) andlimit(default 20). Args: optionalquery, optionallimit, optionalauthToken.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 includesaddr(kind + id),repoCwd,capabilities,pid,startedAt, andlastBeat. OptionaljoinRuns=truemergesGlobalRegistryrun metadata (task preview, workflow name, state) for run-kind entries. Presence files live under<CYBERINE_HOME>/bus/presence/; stale (no heartbeat withinttlMs, default 60 s) and tombstoned records are excluded. Args: optionaljoinRuns, optionalauthToken.send_to_team— broadcast a message to a named team'steam-<name>topic. Args:team,text, optionaltopic, optionalauthToken.send_to_role— resolve live presence by{team, role, name}(all optional, ANDed) and publish to every matching address. Args:team,role,name,text, optionaltopic, optionalauthToken.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 (likequery_runs/list_team) — not scoped to this server's own cwd. Args:runId(run UUID), optionalauthToken.approve_run— record an operator approval decision for a run or step. Persisted toapproval.jsonin the run directory and embedded inrun.json. Scoped: rejects arunIdwhose registrycwddoesn'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, optionalcomment, optionalstepId(targets a specific step winner; omit for the whole run), optionalauthToken.reject_run— record an operator rejection decision. Same args, persistence, and project scoping asapprove_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 asapplication/json. MCP clients that support resource reading can fetch this URI directly without invoking a tool. WhenCYBERINE_MCP_AUTH_TOKENis configured, append a matching token as a query param:cyberine://runs/{runId}?authToken=<token>.resources/listenumerates real run instances (backed byGlobalRegistry.listRuns(), capped at 200, newest-updated first) — each entry'snameis the run's task preview anddescriptioncarries workflow/state/cwd, so a client can browse before picking arunId.
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 stdioSDK 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.progressTokenon thetools/callrequest (the MCP SDK'sClient.callTool(params, resultSchema, { onprogress })option does this for you) to receive standardnotifications/progressevents — 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_runtool with the returnedrunId(orquery_runs/GET cyberine://runs/{runId}— any reader ofRunStore/GlobalRegistrysees this run exactly like a WorkflowEngine-authored one).statemovesrunning->done/error/cancelled;steps[0].candidatescarries one candidate per dispatched provider, andsteps[0].verdict/winnerPreviewcarry 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'sstopRun(runId), callable directly from Node). The background dispatch polls this file every 500 ms (mirrorsWorkflowEngine's own cadence); once observed, the in-flightorchestrate()call is aborted and the run settles tocancelled. - Known limitations / deferred work:
query_runs' substring filter matches the livetaskPreview, itself derived fromRun.detail— that field holds the original prompt only while the run is stillrunning; once it settles,detailis overwritten with an outcome summary (matching the conventionWorkflowEngine's own runs already follow), so a substring search for the original prompt text stops matching after completion. Search byworkflowName(mcp-orchestrate),cwd, or therunIditself instead. There is also no dedicatedcancel_runMCP 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)— thesynthesizestep'swinnerOutputis the machine-readable{ report, markdown, verifiedCount, droppedCount, ... }envelopecyberine researchitself writes to disk; once the run is terminal the same markdown is also written toreportPath(default<cwd>/.local/research/<slug>.md, mirroringcyberine research's own default). - Depth.
quick= 3 angles / 2 skeptics per claim,deep(default) = 5/3,max= 7/5 — same presets ascyberine 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'senginePid(visible inrun.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), andstartBackgroundResearchis reachable and spawns the expected child command. Spend/daily-cap gating is inherited fromWorkflowEngine.run()itself (the same guardcyberine researchand every other engine caller goes through) — this tool does not duplicateorchestrate's own pre-check pass, so a blocked dispatch surfaces once the run settles to anerrorstate rather than as an immediateisErrorreply.
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.
Project groups — link dirs to share runs / chats / memory
A GROUP links several project directories so their runs, chats, and long-term memory collaborate across projects (one Cyberine group = one shared memory pool). Groups are OPT-IN: with no config,...
Consume external MCP servers as tools — McpToolAdapter
Cyberine can consume any external MCP server's tools as orchestrator Tools (the consume-side mirror of npm run mcp, which exposes Cyberine itself). Configure servers by env; nothing connects when...