Spec: MCP Server (Agent Plane)

Status: Implementedpackages/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 the arkaik/io subpath export (§ Reuse Seams); the plugin ships the generated plugin/.mcp.json; tests/mcp/run-mcp-tests.js is 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

DecisionDetail
PackageNew workspace package packages/mcp, npm name arkaik-mcp (unscoped, symmetric with arkaik), MIT — toolchain side of the license split
Bin / install storynpx -y arkaik-mcp is the whole setup; esbuild-bundled single file like the CLI
Transportstdio (v1). The server is spawned per session by the agent host; no daemon, no port
Not a CLI subcommandThe 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:

  1. --bundle <path> argument
  2. ARKAIK_BUNDLE environment variable
  3. docs/arkaik/bundle.json under 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.persist therefore 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 real BundleValidation, not a look-alike. Most read tools touch only bundle/nodes/edges/journal, so a hosted loaded carrying just those passed unnoticed until validate_bundle — which also reads result, valid and sidecarFindings — 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 same validateBundle over 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.

ToolInputReturnsJournal events
list_nodesspecies?, status?, platform?, value?, anchor?, parity_gap?, query?, limit?node summaries {id, title, species, status, platforms} (acceptances add platform_statuses, values)
get_nodenode_idfull node + edges with neighbor titles + where-used flows + computeNodeTimeline + (views/flows) covered_by
create_nodespecies, title, description?, status?, platforms, metadata?created node (id via deriveNodeId) + any synthesized composes edgesnode.created (+ one edge.added per synthesized composes edge — see Playlist Composition)
update_nodenode_id, patchupdated node + any synthesized composes edgesvia diffNodeUpdate: node.updated / node.status_changedplatform) / ref.added / ref.removed (+ edge.added per synthesized composes edge)
delete_nodenode_idremoved node + cascaded edge idsnode.deleted (edge cascade implied per journal.md)
add_edgesource_id, target_id, edge_typecreated edge (id via edgeId)edge.added
remove_edgeedge_idackedge.removed
propose_ideatitle, description?, node_id?eventidea.proposed
file_requesttitle, description?, source?, node_id?eventrequest.filed
get_changelogversion?release list, or one computeChangelog slice
get_backlogcomputeBacklog (open ideas & requests)
list_mapsbuilt-in + stored MapDefinitions with node/edge counts
get_mapmap_idcomputeMapSubgraph result
validate_bundlevalidateBundle findings (errors + warnings, with paths)

list_nodes additionally accepts:

  • value — only acceptances tagged with this value element (enum: the 30 VALUE_IDS).
  • anchor — a node id; only acceptances covering it via a covers edge.
  • 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.

get_map and list_maps apply MapDefinition.product (maps.md § Product Scope): a map scoped to one product returns that product's subgraph here, and those counts, exactly as the canvas draws it. Neither tool resolves membership of its own — the restriction is step 0 of computeMapSubgraph, and this layer adds only a ProductGraph built once so list_maps traverses the snapshot for the whole catalog rather than per map.

That is a deliberate division and not an implementation detail: this used to be applied by the app before the call, so both tools served the unscoped subgraph under a scoped map's own name — audience symmetry broken by nothing more than where a function sat (issue #319). The fix was to move membership resolution into @arkaik/schema, not to reimplement the restriction here, 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.

Quality tools (Kritik)

Eleven kritik_* tools mirror the arkaik kritik verbs (kritik.md § 4.4), which is what makes a scheduled agent audit a first-class monitoring loop: a routine wakes, reads the signal pack, audits what changed since the last audited commit, scores through these tools, and the journal accumulates the quality history the UI renders as trends.

ToolInputReturnsJournal events
kritik_matrixaudit_id?, record?the comparative matrix, per-surface roll-ups, finding counts, priority lanes, the P0 list; refreshes matrix.json in repo mode. Hosted reads the stored quality section instead — no audit_id/commit (hosted has no audit file) — and record is refused therequality.audit.completed (only with record: true, repo mode only — recording belongs to the audit run)
kritik_findingssurface?, status?, priority?, criterion_id?, audit_id?, finding_id?findings with derived severity and priority; finding_id fetches one. Repo mode reads across every audit under docs/quality/audits/; hosted findings are a single living pool with no audit_id partition (the filter is refused there)
kritik_signalssurface?, criterion_id?, domain?the signal run sheet, plus tripped_since_last_audit. Repo sessions only — the pack and its run sheet live with the code
kritik_regressionsfrom?, to?, record?what got worse between two audits: a dropped maturity level, a cell that gained an open Critical or High, a finding resolved and open again. Hosted comparisons are read-only and cover assessment-level drops only — hosted findings are a living pool, not a per-audit snapshot, so the other two regression kinds don't apply therequality.signal.tripped, one per regression (only with record: true, repo mode only)
kritik_trendsurface?, domain?where the product stood at each recorded audit: one row per quality.audit.completed, oldest first, the overall score per surface (or one domain's with domain) and its delta against the row above. A re-recorded audit id keeps its latest reading; a framework major bump marks the later row comparable: false with no delta across it. Both modes — hosted reads the hosted journal
kritik_issuecriterion_id, surface, level?, finding_id?the prefilled GitHub issue skeleton
kritik_scorecriterion_id, surface, level, evidence, audit_id?, commit?the assessment, latest-per-cell. Repo sessions only — scoring reads the code, so a hosted session refuses
kritik_open_findingcriterion_id, surface, title, evidence, impact, likelihood, cost, detail?, remediation?, node_ids?, issue_url?, verification?, audit_id?, finding_id?the finding + its derived severity/priority. Repo sessions only — a new finding cites code, so a hosted session refusesquality.finding.opened (none when verification.verdict is REFUTED)
kritik_resolve_findingfinding_id, resolved_by?the closed findingquality.finding.resolved — repo mode writes it via store.persist; hosted posts it to the host (idempotent either way — a second resolve writes nothing)
kritik_accept_findingfinding_id, notethe finding as an accepted riskrepo mode: none (acceptance is a state, written straight to the findings file); hosted: quality.finding.accepted, since there is no findings file to hold that state — the read derives accepted-risk from the event
kritik_trip_signalcriterion_id, surface, signal, detail?ack. Repo sessions onlyquality.signal.tripped

Four properties of this namespace are worth stating out loud, because each is a decision rather than a detail:

The sidecars are the store, the journal is the gate — in repo mode. Kritik state lives in docs/quality/, canonical in a repository the way journal.jsonl is canonical while a bundle's embedded journal[] is only the interchange projection. So assessments and findings are written to those files, while the quality.* events go through store.persist — the same validator-gated path every other write tool uses. The journal write runs first: a refusal then leaves nothing behind, where the other order would leave a finding on disk that no event ever announced.

Hosted sessions read and transition findings; they don't audit. A hosted project has no docs/quality/ directory — instead the server folds the account's own journal events into the stored bundle's quality section, and kritik_matrix, kritik_findings, kritik_regressions and kritik_issue read that folded section directly, no checkout required (kritik_trend reads the hosted journal the same way). Transitions (kritik_resolve_finding, kritik_accept_finding) go the same way in reverse: the tool posts a whitelisted event — quality.finding.resolved or quality.finding.accepted — to POST /api/graph/projects/{id}/quality/events under the graph:write scope, and the snapshot's quality section is never mutated by that route; current status is re-derived on every read, the same "current state is a projection" doctrine as everywhere else (§3.2), and the same path the GitHub App's own resolution pass uses. What stays repo-only is the audit runkritik_score, kritik_signals, kritik_trip_signal, kritik_open_finding — because scoring a cell and opening a finding both read the code, and an agent pointed at a hosted map has no checkout to read it against; those four refuse with that reason stated, not a blanket "hosted isn't supported". The half of the old repo-only decision that still holds: running an audit needs a working tree — only auditing does, not reading or deciding on what a past audit already found (issue #400).

A few shapes of that split are worth being explicit about: writes are events-only (no snapshot mutation, no new tables — the fold is the only place a hosted finding's status is computed); findings stay a living pool rather than being partitioned by audit_id the way repo findings are; an optional commit anchor on a finding is required by policy for any future hosted-written kritik_open_finding, should that ever ship; the token scope split stays graph:read for the reads above and graph:write for the two transitions; and Publik's stripQuality is unaffected — it strips the whole quality section before either read or write path is reachable.

Nothing here authors a criterion or picks a surface. arkaik kritik profile and arkaik kritik criterion add deliberately have no MCP mirror. Both are one-time design decisions about what this product is and how it should be judged — the choices an audit is measured against, not moves within one — and the loop these tools exist to serve is the audit, not its terms.

Severity is derived, never stored. Severity, priority, scores, grades and caps never appear in stored data and are recomputed on every read; a finding cannot carry a severity its own impact × likelihood disagrees with.

Write Path (dual-write, validator-gated)

Every mutating tool MUST follow, in order:

  1. Load bundle + journal (fresh).
  2. Apply the mutation in memory and derive the matching journal events (shared derivation — see Reuse Seams).
  3. Run validateBundle on 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.
  4. Persist: append each event to the journal sidecar (JSONL, one line per event, actor: "arkaik-mcp"), then rewrite the snapshot with serializeBundle (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 (three enabling moves)

  1. CLI file-IO becomes importable. packages/cli exposes a subpath export arkaik/io (bundle read/write, journal sidecar IO, validation wrapper) built as a second esbuild entry. packages/mcp depends on arkaik and 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.
  2. Dual-write derivation moves to the schema package. lib/data/emit-events.ts is already pure; its core moves to packages/schema/src/derive.ts with the actor as a parameter. The app keeps a thin re-export binding actor: "arkaik-app"; the MCP server binds "arkaik-mcp"; the skill doctrine stays the human-readable statement of the same rules.
  3. The Kritik seam rides the first one. arkaik/io also exports where the criteria pack lives and where quality events go (packages/cli/src/lib/kritik-io.ts), and the operations themselves are @arkaik/schema (quality-ops.ts, cli/kritik-audit.ts, cli/kritik-overlay.ts). The kritik_* tools, the arkaik kritik verbs and the plugin's standalone scripts are therefore three entry points over one implementation — a score written by an agent and a score written by a person are the same write.

Distribution

  • npm: npx -y arkaik-mcp — zero-install for any MCP host.
  • Claude Code plugin: the plugin gains a generated plugin/.mcp.json declaring the server (command: "npx", args: ["-y", "arkaik-mcp"]), emitted by scripts/generate/generate-plugin.js and 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.ts seam. Requires a device-token auth flow (shared open question with arkaik 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.