MCP Server — the Agent Plane
Status: Implemented —
packages/mcp(arkaik-mcp), a dependency-free stdio server: the SDK caveat below resolved to its own fallback (the workspace's zod 4 vs the SDK's zod 3), so the server speaks newline-delimited JSON-RPC directly; the tool catalog is the contract, and adopting the SDK later changes plumbing, not behavior. File IO comes from thearkaik/iosubpath export (§ Reuse Seams); the plugin ships the generatedplugin/.mcp.json;tests/mcp/run-mcp-tests.jsis the harness (§ Testing). This document remains the normative contract. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
Purpose
The skill makes agents good writers of the map; the format makes them competent readers. What's missing is a conversational surface: an agent asked "what's blocked on iOS?" or "link this new endpoint to the checkout view" should not need to parse a 4,000-line JSON file into context or shell out to a CLI with flags. The MCP server exposes the map as tools — the same projections humans see as pages (maps.md, journal.md § Projections), plus validated dual-write mutations.
This is the audience-symmetry principle (vision.md § Core Product) made concrete: every human surface has an agent-consumable twin, produced by the same @arkaik/schema functions.
Packaging & Transport
| Decision | Detail |
|---|---|
| Package | New workspace package packages/mcp, npm name arkaik-mcp (unscoped, symmetric with arkaik), MIT — toolchain side of the license split |
| Bin / install story | npx -y arkaik-mcp is the whole setup; esbuild-bundled single file like the CLI |
| Transport | stdio (v1). The server is spawned per session by the agent host; no daemon, no port |
| Not a CLI subcommand | The arkaik CLI stays dependency-free; the MCP SDK would end that. The CLI usage text gains a pointer line; an arkaik mcp alias MAY wrap the package later |
| SDK note | @modelcontextprotocol/sdk was the default choice; its zod-3 pin against the workspace's zod 4 resolved this to the fallback, taken all the way: the server speaks newline-delimited JSON-RPC directly (src/protocol.ts, ~150 lines — initialize, tools/list, tools/call, ping) with raw JSON-Schema tool definitions whose enums come from @arkaik/schema ids. @arkaik/schema remains the only validation authority; the SDK MAY be adopted later without changing the tool catalog |
Graph Discovery — two modes, one catalog
The server serves a graph from one of two backends. The tool catalog is identical in both, which is the point: the mode is a Store implementation (src/store.ts), not a second set of tools, so repo and hosted behaviour cannot drift.
Repo bundle (Kommit-first, the default)
The map lives next to the code, maintained as a side effect of development. Resolution order:
--bundle <path>argumentARKAIK_BUNDLEenvironment variabledocs/arkaik/bundle.jsonunder the current working directory
The journal is the sidecar resolved by the rules in journal.md § Storage Shapes. The server MUST reload the bundle per tool call (files are small; external edits — a human, another agent, git pull — must be picked up).
Hosted project (the account backend)
The map lives in an arkaik account and is reached over HTTP, so the app, this server, and the GitHub App all read and write one copy — no sync, no merge, no conflict UI (hosted-projects.md, services.md § Boundary 1 no longer holds for hosted projects).
Selected by --remote, or automatically by a docs/arkaik/arkaik.json written by arkaik link. Resolution:
| Order | |
|---|---|
| Project | --project, then $ARKAIK_PROJECT, then arkaik.json |
| Origin | $ARKAIK_URL, then arkaik.json, then https://arkaik.app |
| Token | $ARKAIK_TOKEN only — never a file, because a credential in a repo file is a credential in a git history |
--bundle always wins, so an explicit path overrides a link file.
Three rules this mode MUST hold:
- The client sends OPS, not a mutated graph. The server recomputes under its own row lock, so two writers cannot lose each other's work.
Store.persisttherefore takes an intent for the hosted backend where the file backend takes an outcome. - A linked repo with no token exits non-zero. It MUST NOT fall back to a repo bundle: a silent fallback serves a stale graph that looks fine, which is the failure an agent cannot notice.
load()returns a realBundleValidation, not a look-alike. Most read tools touch onlybundle/nodes/edges/journal, so a hostedloadedcarrying just those passed unnoticed untilvalidate_bundle— which also readsresult,validandsidecarFindings— crashed on it, putting the skill's hard validator gate out of reach for every hosted project. Hosted mode fetches the whole bundle plus its journal, so the findings come from running the samevalidateBundleover the same graph the server validated: one validator, one verdict, either side of the wire.
propose_idea and file_request are refused against a hosted project — the hosted write path has no journal-only operation yet — with an explicit message rather than a silent drop.
Tool Catalog (v1)
All tool results are JSON text content. Read tools are projections; write tools follow the Write Path below.
| Tool | Input | Returns | Journal events |
|---|---|---|---|
list_nodes | species?, status?, platform?, value?, anchor?, parity_gap?, query?, limit? | node summaries {id, title, species, status, platforms} (acceptances add platform_statuses, values) | — |
get_node | node_id | full node + edges with neighbor titles + where-used flows + computeNodeTimeline + (views/flows) covered_by | — |
create_node | species, title, description?, status?, platforms, metadata? | created node (id via deriveNodeId) + any synthesized composes edges | node.created (+ one edge.added per synthesized composes edge — see Playlist Composition) |
update_node | node_id, patch | updated node + any synthesized composes edges | via diffNodeUpdate: node.updated / node.status_changed (± platform) / ref.added / ref.removed (+ edge.added per synthesized composes edge) |
delete_node | node_id | removed node + cascaded edge ids | node.deleted (edge cascade implied per journal.md) |
add_edge | source_id, target_id, edge_type | created edge (id via edgeId) | edge.added |
remove_edge | edge_id | ack | edge.removed |
propose_idea | title, description?, node_id? | event | idea.proposed |
file_request | title, description?, source?, node_id? | event | request.filed |
get_changelog | version? | release list, or one computeChangelog slice | — |
get_backlog | — | computeBacklog (open ideas & requests) | — |
list_maps | — | built-in + stored MapDefinitions with node/edge counts | — |
get_map | map_id | computeMapSubgraph result | — |
validate_bundle | — | validateBundle findings (errors + warnings, with paths) | — |
list_nodes additionally accepts:
value— only acceptances tagged with this value element (enum: the 30VALUE_IDS).anchor— a node id; only acceptances covering it via acoversedge.parity_gap— boolean; only acceptances delivered (live) on ≥1 applicable platform but not all (spec §3.5).
Acceptance summaries additionally carry platform_statuses (resolved per applicable platform) and values. get_node on a view or flow includes covered_by: summaries of the acceptances covering it.
Known gap —
get_mapandlist_mapsignoreMapDefinition.product. A stored map may be scoped to one product (maps.md § Product Scope), but both tools callcomputeMapSubgraphdirectly, and the product restriction is applied by the app (lib/utils/product-scope.ts,mapScopedNodes) before that call rather than inside it. So an agent asking for a product-scoped map gets the unscoped subgraph, andlist_mapsreports the unscoped node and edge counts alongside it — silently, with nothing in the result saying so.This breaks audience symmetry, the principle the rest of this document is built on: every human surface is supposed to have an agent-consumable twin, and here the twin answers a different question than the canvas does. The fix is to move membership resolution into
@arkaik/schemasocomputeMapSubgraphcan take the product itself — not a second implementation in the MCP layer, which is precisely how two surfaces come to disagree about the same node.
arkaik release (tagging, note drafting, compaction) stays CLI-only in v1 — it is a ceremony with side effects beyond the bundle, owned by packages/cli/src/commands/release.ts.
Write Path (dual-write, validator-gated)
Every mutating tool MUST follow, in order:
- Load bundle + journal (fresh).
- Apply the mutation in memory and derive the matching journal events (shared derivation — see Reuse Seams).
- Run
validateBundleon the mutated bundle with the new events folded in. Any error → return the pathed findings and write nothing. Warnings pass through in the tool result. - Persist: append each event to the journal sidecar (JSONL, one line per event,
actor: "arkaik-mcp"), then rewrite the snapshot withserializeBundle(canonical form — clean git diffs).
This is the skill's dual-write doctrine (journal.md § Authority) enforced structurally: an MCP mutation is incapable of the snapshot-without-history drift that free-form file edits invite.
Playlist Composition (composes-edge synthesis)
A flow's playlist and its composes edges are two views of one relationship: the validator's playlist-composes-coherence rule requires a composes edge from a flow to every view/sub-flow its playlist references. Under the gate above this created a deadlock — a flow created with a populated playlist fails coherence because the edges don't exist yet, but add_edge cannot create them until the flow node exists, so no single call could produce a populated flow (issue #263).
create_node and update_node therefore synthesize the required edges: when the mutated node is a flow, they add a composes edge (flow → referenced node) for every playlist reference (recursing through condition/junction branches) that lacks one, fold those edges and their edge.added events into the same validated write, and return them in the tool result under edges. Edges that already exist are never duplicated; a reference to a missing node still fails the gate (nothing is written). This mirrors the app's own playlist editor, which adds the edge and the entry together. A populated flow is thus a single create_node call.
Reuse Seams (two enabling moves)
- CLI file-IO becomes importable.
packages/cliexposes a subpath exportarkaik/io(bundle read/write, journal sidecar IO, validation wrapper) built as a second esbuild entry.packages/mcpdepends onarkaikand imports these verbatim — no drift between what the CLI and the MCP server consider "the bundle on disk". Filesystem code stays out of@arkaik/schema, which remains browser-safe. - Dual-write derivation moves to the schema package.
lib/data/emit-events.tsis already pure; its core moves topackages/schema/src/derive.tswith the actor as a parameter. The app keeps a thin re-export bindingactor: "arkaik-app"; the MCP server binds"arkaik-mcp"; the skill doctrine stays the human-readable statement of the same rules.
Distribution
- npm:
npx -y arkaik-mcp— zero-install for any MCP host. - Claude Code plugin: the plugin gains a generated
plugin/.mcp.jsondeclaring the server (command: "npx", args: ["-y", "arkaik-mcp"]), emitted byscripts/generate/generate-plugin.jsand covered by the CI drift check. Installing the plugin then delivers skill + MCP together: the doctrine and the tools, one install.
Testing
tests/mcp/run-mcp-tests.js follows the CLI harness pattern: spawn the built server against a tmpdir fixture bundle + journal sidecar, speak JSON-RPC over stdio (initialize, tools/list, tools/call), and assert: read-tool shapes; a write round-trip (update → journal line appended → validate_bundle clean → snapshot canonical); and the gate (a mutation that would dangle an edge is refused with pathed findings and the files untouched).
tests/mcp/remote-store.test.js runs the same built server against a stub of the hosted API. Assertions that a tool behaves the same through both stores belong here rather than in the file-mode suite, which by construction cannot see hosted-only drift: validate_bundle returning a verdict identical to repo mode — clean and broken — is checked that way.
Non-Goals (v1)
- Hosted MCP / REST over Synk projects. The natural Klub-tier follow-up: the same tool catalog served over authenticated transport against account-backed projects, enforced on the existing
lib/services/limits.tsseam. Requires a device-token auth flow (shared open question witharkaik push --to synk, services.md). Specified when scheduled — nothing in this document precludes it. - Multi-bundle workspaces — one server instance, one bundle.
- Release ceremony — CLI-only, above.